Що це. WebDriver — це бібліотека клієнта (Java, Python, C# тощо) плюс стандартизований протокол W3C WebDriver, через який тест керує справжнім браузером. Між кодом і браузером стоїть окремий процес-драйвер: chromedriver, geckodriver, msedgedriver або safaridriver. Кожен виклик на кшталт driver.findElement(...) клієнт серіалізує в JSON і відправляє драйверу як HTTP-запит, а драйвер уже нативними засобами браузера виконує дію і повертає JSON-відповідь.
What it is. WebDriver is a client library (Java, Python, C# and others) plus the standard W3C WebDriver protocol that a test uses to drive a real browser. A separate driver process sits between your code and the browser: chromedriver, geckodriver, msedgedriver or safaridriver. Every call such as driver.findElement(...) is serialised to JSON and sent to the driver as an HTTP request, and the driver performs the action through the browser's native automation hooks and returns a JSON response.
Навіщо. Розуміння схеми «одна команда = один HTTP-запит» пояснює більшість практичних речей: чому кожен getText() коштує мережевого кола, чому Grid — це просто проксі для тих самих запитів, чому сесія має ідентифікатор і чому витік сесій забиває ноди. У Selenium 4 старий JSON Wire Protocol прибрали, тож усі браузери говорять одним W3C-діалектом.
Why it matters. Knowing that one command equals one HTTP request explains a lot of day-to-day behaviour: every getText() costs a network round trip, Grid is simply a proxy for those same requests, a session has an id, and leaked sessions block nodes. Selenium 4 dropped the old JSON Wire Protocol, so every browser now speaks the same W3C dialect.
Приклад. driver.findElement(By.id("login")) перетворюється на POST /session/{sessionId}/element з тілом {"using": "css selector", "value": "#login"} — Java-клієнт сам переписує By.id на CSS, бо W3C знає лише п’ять стратегій пошуку. У відповідь приходить посилання на елемент, яке далі йде в POST .../element/{id}/click.
Example. driver.findElement(By.id("login")) becomes POST /session/{sessionId}/element with the body {"using": "css selector", "value": "#login"}. The Java client rewrites By.id into CSS itself, because W3C defines only five location strategies. The response carries an element reference, which is then used in POST .../element/{id}/click.
Підводні камені. Класичний протокол односпрямований: тест питає — браузер відповідає, а сам браузер нічого не повідомляє. Тому логи консолі чи мережеві події доводилось витягувати обхідними шляхами. Це закриває WebDriver BiDi — двонаправлений протокол поверх WebSocket, де браузер сам надсилає події. У Selenium 4 він вмикається капабіліті webSocketUrl і поступово замінює Chrome-специфічний CDP.
Pitfalls. The classic protocol is one-way: the test asks and the browser answers, but the browser never reports anything on its own. That is why console logs and network events used to need workarounds. WebDriver BiDi fixes this with a bidirectional protocol over WebSocket where the browser pushes events. In Selenium 4 it is switched on with the webSocketUrl capability and is gradually replacing the Chrome-only CDP.
Уточнення. Часто питають, чи Selenium — це тестовий фреймворк. Ні: це інструмент автоматизації браузера, а запуск тестів, асерти і звіти дають JUnit 5 або TestNG разом із бібліотеками на кшталт AssertJ.
Follow-up. Interviewers often ask whether Selenium is a test framework. It is not: it automates the browser, while running tests, assertions and reports come from JUnit 5 or TestNG together with libraries like AssertJ.
WebDriver driver = new ChromeDriver(); // старт chromedriver + POST /session
driver.get("https://shop.example.com/login"); // POST /session/{id}/url
WebElement login = driver.findElement(By.id("login"));
// POST /session/{id}/element
// {"using": "css selector", "value": "#login"}
login.click(); // POST /session/{id}/element/{elementId}/click
driver.quit(); // DELETE /session/{id}
WebDriver driver = new ChromeDriver(); // starts chromedriver + POST /session
driver.get("https://shop.example.com/login"); // POST /session/{id}/url
WebElement login = driver.findElement(By.id("login"));
// POST /session/{id}/element
// {"using": "css selector", "value": "#login"}
login.click(); // POST /session/{id}/element/{elementId}/click
driver.quit(); // DELETE /session/{id}
new ChromeDriver() запускає локальний процес драйвера і відкриває сесію запитом POST /session. WebElement — не копія DOM-вузла, а лише посилання з ідентифікатором, який драйвер видав. Коментарі праворуч показують HTTP-метод і шлях W3C, у який перетворюється кожен рядок; {id} — ідентифікатор сесії, {elementId} — ідентифікатор елемента.new ChromeDriver() starts a local driver process and opens a session with POST /session. A WebElement is not a copy of the DOM node, only a reference carrying an id the driver handed out. The comments on the right show the W3C HTTP method and path each line turns into; {id} is the session id and {elementId} is the element id.Що це. Selenium Manager — утиліта на Rust, яку вбудовано в Selenium починаючи з 4.6. Коли ти пишеш new ChromeDriver() і драйвера немає ні в PATH, ні в системній властивості webdriver.chrome.driver, клієнт сам викликає Selenium Manager. Той визначає версію встановленого браузера, завантажує сумісний chromedriver і кладе його в кеш ~/.cache/selenium. Новіші версії вміють ще й завантажити сам браузер Chrome for Testing, якщо його немає або якщо в опціях указано browserVersion.
What it is. Selenium Manager is a Rust utility bundled with Selenium since 4.6. When you write new ChromeDriver() and there is no driver on PATH or in the webdriver.chrome.driver system property, the client calls Selenium Manager on its own. It detects the installed browser version, downloads a matching chromedriver and stores it in the ~/.cache/selenium cache. Newer releases can also download the Chrome for Testing browser itself when it is missing or when the options set a browserVersion.
Навіщо. Раніше команди тримали драйвери в репозиторії або підключали сторонню бібліотеку WebDriverManager, і після автооновлення Chrome зранку падав увесь прогін. Тепер типовий локальний запуск і простий CI обходяться без жодного налаштування драйверів.
Why it matters. Teams used to keep drivers in the repository or pull in the third-party WebDriverManager library, and an overnight Chrome auto-update would break the whole morning run. Now a typical local run and a simple CI job need no driver setup at all.
Приклад. Новачок клонує проєкт, запускає тест — і все працює без кроку «завантаж chromedriver». А щоб зафіксувати версію для відтворюваності, достатньо options.setBrowserVersion("126"): менеджер підтягне саме цю збірку браузера і відповідний драйвер.
Example. A new joiner clones the project, runs a test, and it just works with no "download chromedriver" step. To pin a version for reproducible runs, options.setBrowserVersion("126") is enough: the manager fetches that exact browser build and the driver that goes with it.
Підводні камені. Менеджеру потрібен доступ в інтернет. У закритій корпоративній мережі без проксі-налаштувань він не зможе нічого завантажити, і тоді драйвер кладуть у образ агента заздалегідь та вказують шлях явно. Якщо драйвер уже є в PATH, Selenium візьме саме його, навіть застарілий, — звідси загадкові помилки несумісності версій. На Grid менеджер працює на ноді, а не в тестовому процесі, а в офіційних Docker-образах драйвер і браузер уже зібрані парою.
Pitfalls. The manager needs internet access. In a locked-down corporate network without proxy settings it cannot download anything, so the driver is baked into the agent image in advance and its path is set explicitly. If a driver is already on PATH, Selenium uses that one even when it is outdated, which is where puzzling version mismatch errors come from. On Grid the manager runs on the node rather than in the test process, and the official Docker images already ship a matched browser and driver pair.
Уточнення. Можуть спитати, чи потрібен ще WebDriverManager. Для нових проєктів на Selenium 4.6+ зазвичай ні; він лишається в старих кодових базах, і його прибирають під час міграції.
Follow-up. You may be asked whether WebDriverManager is still needed. For new projects on Selenium 4.6+ it usually is not; it survives in older code bases and gets removed during a migration.
// 1. Нічого не налаштовуємо: Selenium Manager знайде або завантажить chromedriver
WebDriver driver = new ChromeDriver();
// 2. Фіксуємо версію браузера для відтворюваних прогонів
ChromeOptions options = new ChromeOptions();
options.setBrowserVersion("126");
WebDriver pinned = new ChromeDriver(options);
// 3. Офлайн-агент: драйвер лежить в образі, шлях задаємо явно
ChromeDriverService service = new ChromeDriverService.Builder()
.usingDriverExecutable(new File("/opt/drivers/chromedriver"))
.build();
WebDriver offline = new ChromeDriver(service, new ChromeOptions());
// 1. No setup: Selenium Manager finds or downloads chromedriver
WebDriver driver = new ChromeDriver();
// 2. Pin the browser version for reproducible runs
ChromeOptions options = new ChromeOptions();
options.setBrowserVersion("126");
WebDriver pinned = new ChromeDriver(options);
// 3. Offline agent: the driver is in the image, the path is set explicitly
ChromeDriverService service = new ChromeDriverService.Builder()
.usingDriverExecutable(new File("/opt/drivers/chromedriver"))
.build();
WebDriver offline = new ChromeDriver(service, new ChromeOptions());
ChromeDriver() без аргументів запускає пошук драйвера: спершу явний шлях, потім PATH, і лише тоді Selenium Manager. setBrowserVersion — стандартне W3C-капабіліті browserVersion. ChromeDriverService.Builder().usingDriverExecutable(...) вимикає автоматичний пошук і запускає саме вказаний файл.ChromeDriver() constructor starts the driver lookup: an explicit path first, then PATH, and only then Selenium Manager. setBrowserVersion sets the standard W3C browserVersion capability. ChromeDriverService.Builder().usingDriverExecutable(...) switches the automatic lookup off and launches exactly the given file.Що це. Капабіліті — це JSON-набір побажань до сесії, який клієнт надсилає в POST /session. Частина ключів стандартна для W3C: browserName, browserVersion, platformName, pageLoadStrategy, acceptInsecureCerts, unhandledPromptBehavior. Решта — вендорські, з префіксом: goog:chromeOptions, moz:firefoxOptions. Класи ChromeOptions, FirefoxOptions, EdgeOptions — типобезпечні обгортки, які самі збирають цей JSON.
What it is. Capabilities are a JSON set of session requirements the client sends in POST /session. Some keys are standard W3C: browserName, browserVersion, platformName, pageLoadStrategy, acceptInsecureCerts, unhandledPromptBehavior. The rest are vendor keys with a prefix, such as goog:chromeOptions and moz:firefoxOptions. The ChromeOptions, FirefoxOptions and EdgeOptions classes are type-safe wrappers that build that JSON for you.
Навіщо. Через опції задають усе, що відрізняє тестовий браузер від звичайного: безголовий режим, розмір вікна, мову, теку завантажень, дозволи, самопідписані сертифікати стенду. На Grid ці ж капабіліті визначають, на яку ноду потрапить сесія.
Why it matters. Options hold everything that makes a test browser different from an ordinary one: headless mode, window size, language, download folder, permissions, the self-signed certificate of a test environment. On Grid the same capabilities decide which node the session lands on.
Приклад. Для CI-прогону в Chrome додаємо --headless=new і --window-size=1920,1080, через prefs вимикаємо діалог збереження файлів, а PageLoadStrategy.EAGER повертає керування після DOMContentLoaded, не чекаючи важких картинок. У Firefox те саме робиться через addPreference.
Example. For a CI run in Chrome we add --headless=new and --window-size=1920,1080, switch off the save-file dialog through prefs, and PageLoadStrategy.EAGER hands control back after DOMContentLoaded without waiting for heavy images. Firefox does the same through addPreference.
Підводні камені. Аргументи командного рядка специфічні для браузера: прапорець Chrome, переданий у Firefox, просто проігнорується, і ти будеш шукати баг не там. Копіювати з інтернету «магічні» прапорці на кшталт --no-sandbox без розуміння теж шкідливо: вони можуть приховати реальну проблему середовища. І ще: у Selenium 4 окремий клас DesiredCapabilities більше не потрібен — опції вже є капабіліті, а довільний ключ додають через setCapability.
Pitfalls. Command-line arguments are browser specific: a Chrome flag passed to Firefox is silently ignored, and you end up hunting the bug in the wrong place. Copying magic flags such as --no-sandbox from the internet without understanding them is also harmful, because they can hide a real environment problem. And in Selenium 4 you no longer need a separate DesiredCapabilities object: options already are capabilities, and any extra key goes in through setCapability.
Уточнення. Часто просять пояснити pageLoadStrategy: normal чекає на подію load, eager — лише на DOMContentLoaded, none повертається одразу, і тоді всі очікування лягають на тест.
Follow-up. You are often asked to explain pageLoadStrategy: normal waits for the load event, eager only for DOMContentLoaded, and none returns at once, which puts all the waiting on the test.
ChromeOptions chrome = new ChromeOptions();
chrome.addArguments("--headless=new", "--window-size=1920,1080", "--lang=uk");
chrome.setExperimentalOption("prefs", Map.of(
"download.default_directory", "/tmp/downloads",
"download.prompt_for_download", false));
chrome.setPageLoadStrategy(PageLoadStrategy.EAGER); // не чекати на картинки
chrome.setAcceptInsecureCerts(true); // самопідписаний сертифікат стенду
WebDriver driver = new ChromeDriver(chrome);
FirefoxOptions firefox = new FirefoxOptions();
firefox.addArguments("-headless");
firefox.addPreference("intl.accept_languages", "uk");
firefox.setCapability("se:name", "checkout-smoke"); // довільний ключ, наприклад для Grid UI
ChromeOptions chrome = new ChromeOptions();
chrome.addArguments("--headless=new", "--window-size=1920,1080", "--lang=uk");
chrome.setExperimentalOption("prefs", Map.of(
"download.default_directory", "/tmp/downloads",
"download.prompt_for_download", false));
chrome.setPageLoadStrategy(PageLoadStrategy.EAGER); // do not wait for images
chrome.setAcceptInsecureCerts(true); // self-signed test certificate
WebDriver driver = new ChromeDriver(chrome);
FirefoxOptions firefox = new FirefoxOptions();
firefox.addArguments("-headless");
firefox.addPreference("intl.accept_languages", "uk");
firefox.setCapability("se:name", "checkout-smoke"); // any extra key, e.g. for the Grid UI
addArguments додає прапорці запуску браузера. setExperimentalOption("prefs", Map.of(...)) пише внутрішні налаштування профілю Chrome. setPageLoadStrategy і setAcceptInsecureCerts — стандартні W3C-капабіліті. addPreference — аналог prefs для Firefox. setCapability кладе будь-який ключ, тут se:name, яким Grid підписує сесію.addArguments appends browser launch flags. setExperimentalOption("prefs", Map.of(...)) writes internal Chrome profile settings. setPageLoadStrategy and setAcceptInsecureCerts are standard W3C capabilities. addPreference is the Firefox counterpart of prefs. setCapability puts in any key, here se:name, which Grid uses to label the session.Що це. close() закриває лише поточне вікно чи вкладку. quit() закриває всі вікна, видаляє сесію запитом DELETE /session/{id} і зупиняє локальний процес драйвера. Після close() останнього вікна сесія за W3C теж завершується, але процес chromedriver лишається жити, а будь-яка наступна команда кидає NoSuchSessionException.
What it is. close() closes only the current window or tab. quit() closes every window, deletes the session with DELETE /session/{id} and stops the local driver process. Under W3C the session also ends after close() on the last window, but the chromedriver process stays alive and any further command throws NoSuchSessionException.
Навіщо. Прибирання після тесту — питання ресурсів. Кожен незакритий браузер з’їдає сотні мегабайт пам’яті на агенті, а на Grid незавершена сесія займає слот ноди, доки її не прибере таймаут простою (типово 300 секунд). Сотня тестів без quit() — і черга Grid стоїть, хоча тести вже давно завершились.
Why it matters. Cleaning up after a test is about resources. Every browser left open eats hundreds of megabytes on the agent, and on Grid an unfinished session holds a node slot until the idle timeout removes it (300 seconds by default). A hundred tests without quit() and the Grid queue stalls even though the tests finished long ago.
Приклад. У тесті відкрили вкладку з договором: закриваємо її через close() і повертаємося у головне вікно за збереженим handle. А в @AfterEach викликаємо quit() з перевіркою на null, бо створення драйвера в @BeforeEach теж може впасти.
Example. A test opens a tab with a contract: we close that tab with close() and switch back to the main window using the saved handle. In @AfterEach we call quit() behind a null check, because creating the driver in @BeforeEach can fail too.
Підводні камені. Якщо quit() стоїть в останньому рядку самого тесту, то при падінні асерта він не виконається — тому прибирання живе лише в @AfterEach або в finally. Друга пастка — close() у @AfterEach «для акуратності»: на машині розробника це непомітно, а на агенті CI за день накопичуються десятки зомбі-процесів chromedriver.
Pitfalls. If quit() is the last line of the test itself, a failed assertion skips it, so cleanup belongs in @AfterEach or a finally block. The second trap is calling close() in @AfterEach to be tidy: nobody notices on a laptop, but a CI agent collects dozens of zombie chromedriver processes in a day.
Уточнення. Можуть спитати про @AfterAll. Він годиться, лише якщо браузер свідомо спільний для класу; тоді треба пам’ятати про очищення cookies між тестами, інакше стан одного тесту протече в інший.
Follow-up. You may be asked about @AfterAll. It fits only when the browser is deliberately shared by the whole class, and then you must clear cookies between tests or one test's state leaks into the next.
class ContractTest {
private WebDriver driver;
@BeforeEach
void setUp() {
driver = new ChromeDriver();
}
@Test
void contractOpensInNewTab() {
driver.get("https://shop.example.com/checkout");
String main = driver.getWindowHandle();
driver.findElement(By.linkText("Договір оферти")).click();
// ... перейти у нову вкладку і перевірити її ...
driver.close(); // закрити тільки вкладку з договором
driver.switchTo().window(main); // повернутись, інакше наступна команда впаде
}
@AfterEach
void tearDown() {
if (driver != null) {
driver.quit(); // усі вікна + DELETE /session + стоп chromedriver
}
}
}
class ContractTest {
private WebDriver driver;
@BeforeEach
void setUp() {
driver = new ChromeDriver();
}
@Test
void contractOpensInNewTab() {
driver.get("https://shop.example.com/checkout");
String main = driver.getWindowHandle();
driver.findElement(By.linkText("Terms of sale")).click();
// ... switch to the new tab and check it ...
driver.close(); // close only the contract tab
driver.switchTo().window(main); // switch back or the next command fails
}
@AfterEach
void tearDown() {
if (driver != null) {
driver.quit(); // all windows + DELETE /session + stop chromedriver
}
}
}
@BeforeEach і @AfterEach — анотації JUnit 5, що виконуються до і після кожного @Test, причому @AfterEach спрацьовує навіть після падіння тесту. getWindowHandle() повертає рядковий ідентифікатор поточного вікна. switchTo().window(handle) переводить фокус драйвера назад. Перевірка driver != null захищає від NullPointerException, якщо браузер не стартував.@BeforeEach and @AfterEach are JUnit 5 annotations that run before and after every @Test, and @AfterEach runs even when the test fails. getWindowHandle() returns the string id of the current window. switchTo().window(handle) moves the driver's focus back. The driver != null check prevents a NullPointerException when the browser never started.Що це. Headless — режим, у якому браузер рендерить сторінку повністю, але не показує вікно. У Chrome сучасний варіант вмикається прапорцем --headless=new: це той самий браузер, що й звичайний, а не окрема спрощена збірка, як було раніше. У Firefox прапорець — -headless. Метод setHeadless(true) із Selenium 3 прибрали, тепер лише аргументи.
What it is. Headless is a mode in which the browser renders the page fully but shows no window. In Chrome the modern version is switched on with --headless=new: it is the same browser as the regular one rather than a separate stripped-down build, as it used to be. In Firefox the flag is -headless. The setHeadless(true) method from Selenium 3 is gone; only arguments remain.
Навіщо. На CI-агентах і в контейнерах немає дисплея, а headless не потребує X-сервера чи Xvfb, стартує швидше і споживає менше ресурсів. Для щоденних регресійних прогонів це розумний вибір за замовчуванням.
Why it matters. CI agents and containers have no display, and headless needs no X server or Xvfb, starts faster and uses fewer resources. For daily regression runs it is a sensible default.
Приклад. Тест кошика стабільно зелений локально і падає на CI з ElementClickInterceptedException. Причина — розмір вікна: headless Chrome за замовчуванням стартує з вікном 800×600, сайт перемикається на мобільну верстку, і кнопку перекриває липкий банер. Фікс — явне --window-size=1920,1080 в обох режимах, а не збільшення таймауту.
Example. The cart test is reliably green locally and fails on CI with ElementClickInterceptedException. The cause is the window size: headless Chrome starts with an 800×600 window by default, the site switches to its mobile layout and a sticky banner covers the button. The fix is an explicit --window-size=1920,1080 in both modes, not a longer timeout.
Підводні камені. Headless змінює не лише наявність вікна: інший розмір екрана, інколи інший user agent, відсутність фокуса вікна, свої особливості з буфером обміну і діалогами друку. Деякі антибот-захисти теж впізнають такий браузер. Тому підозріле падіння спершу відтворюють із видимим вікном і тим самим розміром — так видно, що саме бачив тест, а не що ти про це думаєш.
Pitfalls. Headless changes more than the presence of a window: a different screen size, sometimes a different user agent, no window focus, and its own quirks with the clipboard and print dialogs. Some anti-bot protections recognise it as well. So a suspicious failure is first reproduced with a visible window at the same size, which shows what the test actually saw rather than what you assume it saw.
Уточнення. Можуть спитати, чи headless швидший. Трохи — на запуску і ресурсах, але більшість часу тесту все одно йде на мережу й очікування, тому вибір роблять заради середовища, а не заради швидкості.
Follow-up. You may be asked whether headless is faster. Slightly, at start-up and on resources, but most of a test's time goes to the network and to waiting, so the choice is made for the environment rather than for speed.
static ChromeOptions chromeOptions() {
ChromeOptions options = new ChromeOptions();
// однаковий розмір вікна в обох режимах, інакше верстка різна
options.addArguments("--window-size=1920,1080");
boolean headless = Boolean.parseBoolean(System.getProperty("headless", "true"));
if (headless) {
options.addArguments("--headless=new");
}
return options;
}
// локально відтворюємо падіння з CI:
// mvn test -Dtest=CartTest -Dheadless=false
static ChromeOptions chromeOptions() {
ChromeOptions options = new ChromeOptions();
// same window size in both modes, otherwise the layout differs
options.addArguments("--window-size=1920,1080");
boolean headless = Boolean.parseBoolean(System.getProperty("headless", "true"));
if (headless) {
options.addArguments("--headless=new");
}
return options;
}
// reproduce a CI failure locally:
// mvn test -Dtest=CartTest -Dheadless=false
System.getProperty("headless", "true") читає JVM-властивість, передану як -Dheadless=false, а друге значення — дефолт. Boolean.parseBoolean перетворює рядок на boolean. Прапорець --headless=new додається лише за потреби, а розмір вікна — завжди, тож обидва режими бачать однакову верстку.System.getProperty("headless", "true") reads a JVM property passed as -Dheadless=false, and the second value is the default. Boolean.parseBoolean turns the string into a boolean. The --headless=new flag is added only when needed, while the window size is always set, so both modes see the same layout.Що це. Selenium автоматизує лише браузери. Нативний застосунок на Android чи iOS — це не DOM, а дерево нативних віджетів, і жоден браузерний драйвер його не бачить. Для цього існує Appium: окремий сервер, який реалізує той самий протокол W3C WebDriver, але під капотом керує пристроєм через UiAutomator2 або Espresso на Android і XCUITest на iOS.
What it is. Selenium automates browsers only. A native Android or iOS app is not a DOM but a tree of native widgets, and no browser driver can see it. That is what Appium is for: a separate server that implements the same W3C WebDriver protocol but drives the device underneath through UiAutomator2 or Espresso on Android and XCUITest on iOS.
Навіщо це знати. Питання перевіряє, чи розумієш ти межі інструмента і не пообіцяєш на проєкті «покрити мобілку Селеніумом». Водночас спільний протокол означає, що досвід переноситься: Java-клієнт Appium побудований поверх класів Selenium, тож findElement, явні очікування і Page Object працюють так само.
Why it matters. The question checks that you know the tool's limits and will not promise to cover mobile with Selenium on a project. At the same time the shared protocol means the skills carry over: the Appium Java client is built on top of Selenium classes, so findElement, explicit waits and Page Object work the same way.
Приклад. Мобільну версію сайту можна частково перевірити і в Selenium: Chrome уміє емулювати екран телефона через опцію mobileEmulation. Але це лише інший розмір вікна та user agent у десктопному браузері. Нативний екран оплати в застосунку магазину тестують вже через AndroidDriver з Appium і локатори на кшталт AppiumBy.accessibilityId.
Example. The mobile version of a website can be partly checked in Selenium: Chrome can emulate a phone screen through the mobileEmulation option. But that is only a different window size and user agent in a desktop browser. The native payment screen in the shop app is tested with Appium's AndroidDriver and locators such as AppiumBy.accessibilityId.
Підводні камені. Емуляція в Chrome не ловить проблем справжніх пристроїв: жестів, клавіатури, яка перекриває поле, повільної мережі, особливостей Safari на iOS. Інша крайність — тягнути Appium туди, де вистачить адаптивної перевірки сайту: мобільна ферма коштує дорожче й працює повільніше, ніж Grid з браузерами.
Pitfalls. Chrome emulation misses the problems of real devices: gestures, a keyboard covering the input, a slow network, Safari quirks on iOS. The opposite mistake is dragging Appium into places where a responsive check of the site is enough: a device farm costs more and runs slower than a Grid of browsers.
Уточнення. Можуть спитати про гібридні застосунки з WebView. Appium уміє перемикати контекст з NATIVE_APP у WebView, і всередині вже діють звичні веб-локатори.
Follow-up. You may be asked about hybrid apps with a WebView. Appium can switch the context from NATIVE_APP into the WebView, and inside it the usual web locators apply.
// 1. Selenium: адаптивна верстка сайту в емуляції телефона
ChromeOptions chrome = new ChromeOptions();
chrome.setExperimentalOption("mobileEmulation", Map.of(
"deviceMetrics", Map.of("width", 390, "height", 844, "pixelRatio", 3.0)));
WebDriver web = new ChromeDriver(chrome);
web.get("https://shop.example.com");
// 2. Appium: нативний застосунок на Android
UiAutomator2Options caps = new UiAutomator2Options()
.setDeviceName("Pixel_7")
.setApp("/builds/shop.apk");
AndroidDriver app = new AndroidDriver(new URL("http://127.0.0.1:4723"), caps);
app.findElement(AppiumBy.accessibilityId("pay-button")).click();
// 1. Selenium: the site's responsive layout in phone emulation
ChromeOptions chrome = new ChromeOptions();
chrome.setExperimentalOption("mobileEmulation", Map.of(
"deviceMetrics", Map.of("width", 390, "height", 844, "pixelRatio", 3.0)));
WebDriver web = new ChromeDriver(chrome);
web.get("https://shop.example.com");
// 2. Appium: a native Android app
UiAutomator2Options caps = new UiAutomator2Options()
.setDeviceName("Pixel_7")
.setApp("/builds/shop.apk");
AndroidDriver app = new AndroidDriver(new URL("http://127.0.0.1:4723"), caps);
app.findElement(AppiumBy.accessibilityId("pay-button")).click();
mobileEmulation з deviceMetrics задає ширину, висоту і щільність пікселів емульованого екрана. UiAutomator2Options з бібліотеки Appium java-client — типобезпечні капабіліті для Android. AndroidDriver підключається до сервера Appium 2 за адресою http://127.0.0.1:4723. AppiumBy.accessibilityId шукає за content-description елемента.mobileEmulation with deviceMetrics sets the width, height and pixel ratio of the emulated screen. UiAutomator2Options from the Appium java-client library are type-safe Android capabilities. AndroidDriver connects to an Appium 2 server at http://127.0.0.1:4723. AppiumBy.accessibilityId looks an element up by its content description.Що це. Кросбраузерне тестування перевіряє, що критичні сценарії працюють у всіх браузерах, які підтримує продукт. Selenium тут сильний завдяки W3C-стандарту: той самий тест запускається в Chrome, Firefox, Edge і Safari лише зміною опцій.
What it is. Cross-browser testing checks that critical scenarios work in every browser the product supports. Selenium is strong here thanks to the W3C standard: the same test runs in Chrome, Firefox, Edge and Safari by changing only the options.
Навіщо думати про стратегію. Наївний підхід — прогнати всі 800 тестів у чотирьох браузерах — множить час і вартість Grid учетверо, а знаходить здебільшого ті самі баги, що й один браузер. Більшість дефектів — у логіці застосунку, а не в рушії браузера.
Why a strategy matters. The naive approach of running all 800 tests in four browsers multiplies time and Grid cost by four, and mostly finds the same bugs a single browser would. Most defects live in the application logic, not in the browser engine.
Приклад. Беремо аналітику: 68% Chrome, 17% Safari, 9% Edge, 5% Firefox. Повна регресія йде в Chrome на кожен пул-реквест. Смоук із 40 критичних сценаріїв (логін, пошук, кошик, оплата) щоночі проходить матрицею Firefox, Edge і Safari. Браузер вибирається системною властивістю, а матрицю паралельно розгортає CI, тож нічний прогін займає стільки ж часу, скільки найдовша гілка.
Example. Start from analytics: 68% Chrome, 17% Safari, 9% Edge, 5% Firefox. The full regression runs in Chrome on every pull request. A smoke set of 40 critical scenarios (login, search, cart, payment) runs every night as a Firefox, Edge and Safari matrix. The browser is picked through a system property and CI fans the matrix out in parallel, so the nightly run takes as long as its slowest branch.
Підводні камені. Safari запускається лише на macOS через вбудований safaridriver і не живе в Linux-контейнерах, тож потрібен Mac-агент або хмарний провайдер. Браузерні відмінності ховаються в конкретних місцях: нативні date-поля, завантаження файлів, drag and drop, шрифти, політики cookies. Ці зони варто покривати свідомо, а піксельні розбіжності віддати візуальним тестам, а не асертам у Selenium.
Pitfalls. Safari runs only on macOS through the built-in safaridriver and does not live in Linux containers, so you need a Mac agent or a cloud provider. Browser differences hide in specific places: native date inputs, file downloads, drag and drop, fonts, cookie policies. Those areas deserve deliberate coverage, while pixel differences belong to visual tests rather than to Selenium assertions.
Уточнення. Спитають, як обирати версії. Зазвичай поточна стабільна і попередня мажорна; стару версію закріплюють через browserVersion, щоб падіння можна було відтворити.
Follow-up. You will be asked how to pick versions. Usually the current stable and the previous major; an older version is pinned with browserVersion so that a failure can be reproduced.
static WebDriver createDriver() {
String browser = System.getProperty("browser", "chrome");
return switch (browser) {
case "firefox" -> new FirefoxDriver(new FirefoxOptions());
case "edge" -> new EdgeDriver(new EdgeOptions());
case "safari" -> new SafariDriver(new SafariOptions()); // лише macOS
default -> new ChromeDriver(new ChromeOptions());
};
}
@Tag("cross-browser") // потрапляє в нічну матрицю
@Test
void guestCanPayForCart() {
// ... критичний сценарій ...
}
// CI: mvn test -Dgroups=cross-browser -Dbrowser=firefox
static WebDriver createDriver() {
String browser = System.getProperty("browser", "chrome");
return switch (browser) {
case "firefox" -> new FirefoxDriver(new FirefoxOptions());
case "edge" -> new EdgeDriver(new EdgeOptions());
case "safari" -> new SafariDriver(new SafariOptions()); // macOS only
default -> new ChromeDriver(new ChromeOptions());
};
}
@Tag("cross-browser") // goes into the nightly matrix
@Test
void guestCanPayForCart() {
// ... critical scenario ...
}
// CI: mvn test -Dgroups=cross-browser -Dbrowser=firefox
switch зі стрілками (Java 14+) повертає значення без break. Кожна гілка створює драйвер зі своїм класом опцій. @Tag з JUnit 5 позначає тест, а параметр Maven Surefire -Dgroups=cross-browser запускає лише позначені. -Dbrowser=firefox потрапляє в System.getProperty.switch expression (Java 14+) returns a value without break. Each branch builds a driver with its own options class. JUnit 5 @Tag labels a test, and the Maven Surefire parameter -Dgroups=cross-browser runs only the labelled ones. -Dbrowser=firefox ends up in System.getProperty.Що це. Стабільний локатор спирається на контракт між тестом і розробкою, а не на випадкову форму DOM. Такий контракт — це атрибут data-testid, унікальний і незмінний id, атрибут name поля форми або доступні атрибути на кшталт aria-label. Позиція елемента в дереві, згенеровані класи CSS-модулів і текст, який перекладають, контрактом не є.
What it is. A stable locator relies on a contract between the tests and the developers, not on the accidental shape of the DOM. That contract is a data-testid attribute, a unique and fixed id, the name of a form field, or accessibility attributes such as aria-label. The element's position in the tree, class names generated by CSS modules and translated text are not a contract.
Навіщо. Більшість «падінь без багу» в UI-наборах — це локатори, що зламалися після рефакторингу верстки. Коли локатор описує призначення елемента, дизайнер може переставити блоки, змінити стилі чи обгорнути кнопку в новий div, і тест це переживе. Порядок вибору, який я пропоную команді: test id → стабільний id або name → доступні атрибути → короткий CSS за змістовними атрибутами → XPath лише коли без нього ніяк.
Why it matters. Most UI failures that are not real bugs come from locators that broke after a markup refactor. When a locator describes what the element is for, a designer can move blocks around, restyle them or wrap a button in a new div, and the test survives. The order I suggest to a team: test id, then a stable id or name, then accessibility attributes, then a short CSS selector on meaningful attributes, and XPath only when nothing else works.
Приклад. Скопійований з DevTools шлях /html/body/div[3]/div[2]/form/button[1] ламається, щойно над формою з’являється банер. Натомість By.cssSelector("[data-testid=checkout-submit]") описує саму кнопку оформлення замовлення і не залежить від сусідів. Для полів форми часто досить By.name("email"), бо name потрібен бекенду і змінюється рідко.
Example. A path copied from DevTools such as /html/body/div[3]/div[2]/form/button[1] breaks the moment a banner appears above the form. By.cssSelector("[data-testid=checkout-submit]") instead points at the checkout button itself and does not care about its neighbours. For form fields By.name("email") is often enough, because the backend needs the name and it rarely changes.
Підводні камені. Класи на кшталт .btn_x7f3a генерує збирач і вони змінюються з кожним білдом. Текст кнопки ламається при локалізації і A/B-тестах. Динамічні id типу ember1234 виглядають стабільними, але залежать від порядку рендеру. Ще одна пастка — локатор, що знаходить кілька елементів: findElement мовчки поверне перший, і тест клікне не туди.
Pitfalls. Classes like .btn_x7f3a are produced by the bundler and change with every build. Button text breaks with localisation and A/B experiments. Dynamic ids such as ember1234 look stable but depend on render order. Another trap is a locator that matches several elements: findElement silently returns the first one, and the test clicks the wrong thing.
Уточнення. На питання «а якщо розробники не додають test id?» відповідаю, що це домовленість на рівні команди: тестувальник робить PR з атрибутами сам або вносить їх у Definition of Done для нових компонентів. Локатори зберігаю в одному місці — Page Object, — щоб правка була в одному рядку.
Follow-up. If asked what to do when developers do not add test ids, I say it is a team agreement: the tester opens a PR with the attributes, or the attributes go into the Definition of Done for new components. I keep locators in one place, a Page Object, so a fix is a one-line change.
// Крихко: залежить від структури сторінки і збірки
By absolute = By.xpath("/html/body/div[3]/div[2]/form/button[1]");
By generated = By.cssSelector(".btn_x7f3a");
By byText = By.xpath("//button[text()='Оформити']");
// Стабільно: описує призначення елемента
By submit = By.cssSelector("[data-testid='checkout-submit']");
By email = By.name("email");
By search = By.cssSelector("input[aria-label='Search products']");
@Test
void checkoutButtonIsUnique() {
// Контракт: рівно один елемент на сторінці
assertEquals(1, driver.findElements(submit).size());
}
// Brittle: depends on page structure and the build
By absolute = By.xpath("/html/body/div[3]/div[2]/form/button[1]");
By generated = By.cssSelector(".btn_x7f3a");
By byText = By.xpath("//button[text()='Checkout']");
// Stable: describes what the element is for
By submit = By.cssSelector("[data-testid='checkout-submit']");
By email = By.name("email");
By search = By.cssSelector("input[aria-label='Search products']");
@Test
void checkoutButtonIsUnique() {
// Contract: exactly one element on the page
assertEquals(1, driver.findElements(submit).size());
}
By — об’єкт-опис пошуку, сам по собі він нічого не шукає. By.cssSelector("[data-testid='...']") — пошук за атрибутом у квадратних дужках, By.name — за атрибутом name. У тесті findElements(...).size() перевіряє, що локатор унікальний, бо findElements повертає список усіх збігів.By is a description of a search and finds nothing by itself. By.cssSelector("[data-testid='...']") matches by attribute in square brackets, and By.name matches the name attribute. The test uses findElements(...).size() to prove the locator is unique, because findElements returns a list of every match.Що це. Обидві мови описують шлях до вузла DOM, але по-різному. CSS — мова селекторів зі стилів: атрибути, класи, нащадки, сусіди, псевдокласи на кшталт :nth-child. XPath — мова запитів до дерева XML, яка вміє рухатися вгору до батька, фільтрувати за текстом і будувати умови з and та or.
What it is. Both languages describe a path to a DOM node, but in different ways. CSS is the selector language from stylesheets: attributes, classes, descendants, siblings and pseudo-classes like :nth-child. XPath is a query language for XML trees that can move up to a parent, filter by text and combine conditions with and and or.
Навіщо. CSS зазвичай коротший, його легше читати, і браузер обчислює його тим самим рушієм, що й стилі. Тому за замовчуванням я пишу CSS. XPath лишаю для задач, яких CSS не вміє: знайти елемент за видимим текстом, піднятися від знайденого елемента до предка або вибрати рядок таблиці за вмістом однієї з клітинок.
Why it matters. CSS is usually shorter and easier to read, and the browser evaluates it with the same engine it uses for styles. So CSS is my default. I keep XPath for jobs CSS cannot do: finding an element by its visible text, climbing from a found element to an ancestor, or picking a table row by what one of its cells contains.
Приклад. Кнопка видалення в рядку таблиці, де в першій клітинці ім’я користувача «Olena»: //tr[td[normalize-space()='Olena']]//button[@data-action='delete']. У CSS немає пошуку за текстом і немає переходу до батька, тож тут XPath чесно виграє. А поле пошуку input[name=q] на XPath виглядало б як //input[@name='q'] — довше і без жодної вигоди.
Example. The delete button in the table row whose first cell holds the user name Olena: //tr[td[normalize-space()='Olena']]//button[@data-action='delete']. CSS has no text matching and no step up to a parent, so XPath honestly wins here. The search field input[name=q], on the other hand, would be //input[@name='q'] in XPath, which is longer and gains nothing.
Підводні камені. Міф про те, що XPath завжди повільний, давно не актуальний: різниця в сучасних браузерах мізерна порівняно з мережевим колом кожної команди. Справжня проблема — крихкість: довгі XPath з індексами div[4] ламаються так само, як абсолютні шляхи. Ще одна пастка: text() не бачить текст вкладених тегів, тому надійніше normalize-space() або contains(., ...).
Pitfalls. The claim that XPath is always slow is outdated: in modern browsers the difference is tiny next to the network round trip every command already pays. The real problem is brittleness, since long XPath full of indexes like div[4] breaks just like absolute paths do. Another trap: text() ignores the text of nested tags, so normalize-space() or contains(., ...) is safer.
Уточнення. Часто просять пояснити // проти /: подвійна риска шукає нащадка на будь-якій глибині, одинарна — лише прямого нащадка. У CSS аналоги — пробіл і >.
Follow-up. You may be asked about // versus /: the double slash finds a descendant at any depth, the single slash only a direct child. In CSS the equivalents are a space and >.
// CSS: коротко і читабельно для атрибутів та ієрархії
By searchBox = By.cssSelector("form#search > input[name='q']");
By firstResult = By.cssSelector("ul.results li:first-child a");
// XPath: пошук за текстом і перехід до предка
By deleteOlena = By.xpath(
"//tr[td[normalize-space()='Olena']]//button[@data-action='delete']");
By labelledInput = By.xpath(
"//label[normalize-space()='Email']/following-sibling::input");
@Test
void deletesUserByName() {
driver.findElement(deleteOlena).click();
// Рядок з Olena зник із таблиці
assertTrue(driver.findElements(deleteOlena).isEmpty());
}
// CSS: short and readable for attributes and hierarchy
By searchBox = By.cssSelector("form#search > input[name='q']");
By firstResult = By.cssSelector("ul.results li:first-child a");
// XPath: matching by text and climbing to an ancestor
By deleteOlena = By.xpath(
"//tr[td[normalize-space()='Olena']]//button[@data-action='delete']");
By labelledInput = By.xpath(
"//label[normalize-space()='Email']/following-sibling::input");
@Test
void deletesUserByName() {
driver.findElement(deleteOlena).click();
// The row with Olena is gone from the table
assertTrue(driver.findElements(deleteOlena).isEmpty());
}
> у CSS — прямий нащадок, :first-child — перший серед сусідів. У XPath tr[td[...]] означає «рядок, у якого є клітинка з умовою», @data-action — звернення до атрибута, following-sibling::input — вісь, що шукає наступного сусіда. normalize-space() обрізає пробіли і враховує текст вкладених тегів.> means a direct child and :first-child the first among its siblings. In XPath tr[td[...]] means a row that has a cell meeting the condition, @data-action reads an attribute, and following-sibling::input is an axis that looks for the next sibling. normalize-space() trims whitespace and includes the text of nested tags.Що це. Відносні локатори (раніше їх називали friendly locators) з’явилися в Selenium 4 у класі RelativeLocator. Ви задаєте, що шукати, через with(By...), а потім уточнюєте положення відносно іншого елемента: above, below, toLeftOf, toRightOf і near. Умови можна поєднувати в ланцюжок.
What it is. Relative locators, first called friendly locators, arrived in Selenium 4 in the RelativeLocator class. You say what to look for with with(By...) and then narrow it down by position relative to another element: above, below, toLeftOf, toRightOf and near. The conditions can be chained.
Навіщо. Вони допомагають, коли у потрібного елемента немає власних стабільних атрибутів, але поруч є надійний орієнтир. Типовий випадок — поле без id і name під підписом «Пароль» або кнопка праворуч від кнопки «Скасувати» у діалозі від сторонньої бібліотеки, яку не можна розмітити test id.
Why it matters. They help when the target element has no stable attributes of its own but has a reliable landmark nearby. The typical case is a field with no id or name under a Password caption, or the button to the right of Cancel in a dialog from a third-party library that you cannot tag with test ids.
Приклад. driver.findElement(with(By.tagName("input")).below(By.id("email"))) знаходить поле, яке на екрані розташоване нижче за поле email. Під капотом Selenium спочатку знаходить усіх кандидатів за By.tagName, потім через JavaScript бере їхні координати getBoundingClientRect() і відфільтровує та сортує їх за відстанню до орієнтира. near за замовчуванням шукає в межах 50 пікселів.
Example. driver.findElement(with(By.tagName("input")).below(By.id("email"))) finds the field that is rendered below the email field. Under the hood Selenium first finds every candidate with By.tagName, then uses JavaScript to read their getBoundingClientRect() coordinates, and filters and sorts them by distance to the landmark. near looks within 50 pixels by default.
Підводні камені. Відносні локатори працюють із відмальованою розкладкою, а не з DOM. Тому адаптивна верстка їх ламає: на вузькому екрані дві колонки стають однією, і «праворуч» перетворюється на «нижче». Елемент поза вікном, схований або ще не відмальований дає непередбачуваний результат. Ще одна пастка: below означає «будь-де нижче», тож при кількох кандидатах результат залежить від сортування за відстанню.
Pitfalls. Relative locators work on the rendered layout, not on the DOM. Responsive design breaks them: on a narrow screen two columns collapse into one and "to the right" becomes "below". An element that is off screen, hidden or not rendered yet gives unpredictable results. And below means anywhere below, so with several candidates the result depends on the distance sorting.
Уточнення. Я використовую їх як запасний варіант і завжди фіксую розмір вікна в тестах. Якщо можна додати test id, це надійніше за будь-яку геометрію.
Follow-up. I treat them as a fallback and always pin the window size in tests. If a test id can be added, it beats any geometry.
import static org.openqa.selenium.support.locators.RelativeLocator.with;
@Test
void logsInThroughUnlabelledForm() {
// Фіксований розмір вікна: геометрія не має залежати від екрана
driver.manage().window().setSize(new Dimension(1366, 900));
WebElement email = driver.findElement(By.id("email"));
email.sendKeys("qa@example.com");
// Поле введення, що відмальоване під полем email
WebElement password = driver.findElement(with(By.tagName("input")).below(email));
password.sendKeys("s3cret");
// Кнопка праворуч від «Скасувати» і нижче за пароль
WebElement confirm = driver.findElement(
with(By.tagName("button")).toRightOf(By.id("cancel")).below(password));
confirm.click();
}
import static org.openqa.selenium.support.locators.RelativeLocator.with;
@Test
void logsInThroughUnlabelledForm() {
// Fixed window size: geometry must not depend on the screen
driver.manage().window().setSize(new Dimension(1366, 900));
WebElement email = driver.findElement(By.id("email"));
email.sendKeys("qa@example.com");
// The input rendered below the email field
WebElement password = driver.findElement(with(By.tagName("input")).below(email));
password.sendKeys("s3cret");
// The button right of Cancel and below the password field
WebElement confirm = driver.findElement(
with(By.tagName("button")).toRightOf(By.id("cancel")).below(password));
confirm.click();
}
import static ... RelativeLocator.with дає змогу писати with(...) без імені класу. with(By.tagName("input")) задає кандидатів, а .below(email) приймає або готовий WebElement, або By орієнтира. Виклики .toRightOf(...).below(...) поєднують умови через «і». Результат — звичайний By, тож його можна передати у findElement чи очікування.import static ... RelativeLocator.with lets you write with(...) without the class name. with(By.tagName("input")) sets the candidates, and .below(email) takes either a ready WebElement or the landmark's By. Chaining .toRightOf(...).below(...) combines the conditions with a logical and. The result is an ordinary By, so it can go into findElement or a wait.Що це. Shadow DOM — це інкапсульоване піддерево веб-компонента, прикріплене до елемента-хоста. Звичайний driver.findElement шукає лише у світлому DOM документа, тому поле всередині, скажімо, <my-login> для нього невидиме. У Selenium 4 є метод WebElement.getShadowRoot(), який повертає SearchContext тіньового кореня, і шукати треба вже в ньому.
What it is. Shadow DOM is the encapsulated subtree of a web component, attached to a host element. A plain driver.findElement only searches the light DOM of the document, so a field inside, say, <my-login> is invisible to it. Selenium 4 adds WebElement.getShadowRoot(), which returns the shadow root as a SearchContext, and that is where you search.
Навіщо. Дизайн-системи на Lit, Stencil чи Salesforce Lightning, а також вбудовані контроли браузера широко використовують Shadow DOM. Без правильного підходу тест падає з NoSuchElementException, хоча в DevTools елемент видно, і команда витрачає години на пошук причини.
Why it matters. Design systems built on Lit, Stencil or Salesforce Lightning, and the browser's own built-in controls, use Shadow DOM heavily. Without the right approach the test fails with NoSuchElementException while the element is clearly visible in DevTools, and the team loses hours looking for the reason.
Приклад. Знаходимо хост By.cssSelector("my-login"), беремо getShadowRoot() і вже в ньому шукаємо input[name=password]. Якщо компоненти вкладені, ланцюжок повторюється: хост, тіньовий корінь, внутрішній хост, його тіньовий корінь, і так до потрібного рівня.
Example. Find the host with By.cssSelector("my-login"), call getShadowRoot(), and search for input[name=password] inside it. With nested components the chain repeats: host, shadow root, inner host, its shadow root, down to the level you need.
Підводні камені. Усередині тіньового кореня в Chromium надійно працює лише CSS: XPath там не підтримується і дає помилку про недійсний локатор. Закритий тіньовий корінь (mode: closed) взагалі недоступний, і getShadowRoot() кидає NoSuchShadowRootException, так само як для елемента без тіньового кореня. Посилання на корінь теж може застаріти після перерендеру компонента, тому його варто знаходити заново, а не кешувати в полі класу.
Pitfalls. Inside a shadow root Chromium reliably supports CSS only: XPath is not supported there and fails with an invalid locator error. A closed shadow root (mode: closed) cannot be reached at all, and getShadowRoot() throws NoSuchShadowRootException, just as it does for an element with no shadow root. The root reference can also go stale after the component re-renders, so find it again rather than caching it in a field.
Уточнення. Питають і про старий спосіб через executeScript("return arguments[0].shadowRoot"). Він працював у Selenium 3, але в Selenium 4 є рідний метод, і обхід через JavaScript уже не потрібен.
Follow-up. Interviewers also ask about the old trick with executeScript("return arguments[0].shadowRoot"). It was needed in Selenium 3, but Selenium 4 has a native method and the JavaScript workaround is no longer necessary.
@Test
void logsInThroughWebComponent() {
// Хост у світлому DOM і його тіньовий корінь
WebElement host = driver.findElement(By.cssSelector("my-login"));
SearchContext loginRoot = host.getShadowRoot();
// Усередині кореня — лише CSS-селектори
loginRoot.findElement(By.cssSelector("input[name='email']")).sendKeys("qa@example.com");
loginRoot.findElement(By.cssSelector("input[name='password']")).sendKeys("s3cret");
// Кнопка — окремий вкладений компонент зі своїм коренем
SearchContext buttonRoot = loginRoot
.findElement(By.cssSelector("my-button"))
.getShadowRoot();
buttonRoot.findElement(By.cssSelector("button")).click();
}
@Test
void logsInThroughWebComponent() {
// The host in the light DOM and its shadow root
WebElement host = driver.findElement(By.cssSelector("my-login"));
SearchContext loginRoot = host.getShadowRoot();
// Inside the root: CSS selectors only
loginRoot.findElement(By.cssSelector("input[name='email']")).sendKeys("qa@example.com");
loginRoot.findElement(By.cssSelector("input[name='password']")).sendKeys("s3cret");
// The button is a nested component with its own root
SearchContext buttonRoot = loginRoot
.findElement(By.cssSelector("my-button"))
.getShadowRoot();
buttonRoot.findElement(By.cssSelector("button")).click();
}
getShadowRoot() повертає SearchContext — той самий інтерфейс, що має WebDriver і WebElement, тому на ньому доступні findElement і findElements. Ланцюжок loginRoot.findElement(...).getShadowRoot() спускається на рівень глибше у вкладений компонент. Лапки всередині селектора одинарні, щоб не екранувати подвійні в Java-рядку.getShadowRoot() returns a SearchContext, the same interface WebDriver and WebElement implement, so findElement and findElements are available on it. The chain loginRoot.findElement(...).getShadowRoot() goes one level deeper into a nested component. The selector uses single quotes so the double quotes of the Java string need no escaping.Що це. findElement повертає перший збіг або кидає NoSuchElementException, якщо нічого не знайшов. findElements повертає List<WebElement> з усіма збігами і ніколи не кидає виняток через відсутність елементів — просто дає порожній список. Обидва методи є і на WebDriver, і на WebElement: у другому випадку пошук обмежується піддеревом цього елемента.
What it is. findElement returns the first match or throws NoSuchElementException when nothing is found. findElements returns a List<WebElement> with every match and never throws for missing elements, it just gives an empty list. Both methods exist on WebDriver and on WebElement; on an element the search is limited to that element's subtree.
Навіщо. Різна поведінка підказує, де що брати. Коли елемент обов’язковий, findElement одразу падає з ясною помилкою. Коли треба перевірити відсутність чи порахувати кількість, findElements дає це без try/catch. Пошук від батька потрібен для повторюваних блоків: картки товарів, рядки таблиць, коментарі, де всередині кожного блоку однакові класи.
Why it matters. The different behaviour tells you which to use. When an element is mandatory, findElement fails straight away with a clear error. When you need to prove absence or count items, findElements does it without try/catch. Searching from a parent is essential for repeated blocks such as product cards, table rows or comments, where every block uses the same classes inside.
Приклад. У каталозі десять карток з однаковою розміткою. Спершу знаходимо картку за назвою товару, а вже в ній — ціну і кнопку «У кошик». Так тест ніколи не візьме ціну з сусідньої картки, навіть якщо порядок карток зміниться.
Example. A catalogue shows ten cards with identical markup. First find the card by product name, then look inside it for the price and the Add to cart button. That way the test never picks up the price of a neighbouring card, even if the order of the cards changes.
Підводні камені. Якщо увімкнено неявне очікування, findElements для відсутнього елемента чекає весь його таймаут, перш ніж повернути порожній список, і перевірки відсутності стають повільними. Інша класична помилка — XPath у пошуку від елемента: //span шукає по всьому документу, а не в батьку, тому потрібна крапка на початку, .//span. Для CSS такої проблеми немає.
Pitfalls. With an implicit wait switched on, findElements for a missing element waits out the whole timeout before returning an empty list, so absence checks become slow. Another classic mistake is XPath in a search from an element: //span searches the whole document, not the parent, so it needs a leading dot, .//span. CSS does not have this problem.
Уточнення. Питають, як перевірити, що елемента немає. Я пишу assertTrue(driver.findElements(by).isEmpty()) або чекаю invisibilityOfElementLocated, а не ловлю виняток від findElement.
Follow-up. You may be asked how to check that an element is absent. I write assertTrue(driver.findElements(by).isEmpty()) or wait for invisibilityOfElementLocated, instead of catching the exception from findElement.
@Test
void addsKeyboardToCart() {
List<WebElement> cards = driver.findElements(By.cssSelector("[data-testid='product-card']"));
assertFalse(cards.isEmpty(), "Каталог порожній");
// Картка з потрібною назвою
WebElement keyboard = cards.stream()
.filter(c -> c.findElement(By.cssSelector(".title")).getText().equals("Keyboard K2"))
.findFirst()
.orElseThrow();
// Пошук лише всередині цієї картки
String price = keyboard.findElement(By.cssSelector(".price")).getText();
keyboard.findElement(By.xpath(".//button[@data-action='add']")).click();
assertEquals("$79", price);
// Банер про помилку не з'явився
assertTrue(driver.findElements(By.cssSelector(".error-banner")).isEmpty());
}
@Test
void addsKeyboardToCart() {
List<WebElement> cards = driver.findElements(By.cssSelector("[data-testid='product-card']"));
assertFalse(cards.isEmpty(), "Catalogue is empty");
// The card with the right title
WebElement keyboard = cards.stream()
.filter(c -> c.findElement(By.cssSelector(".title")).getText().equals("Keyboard K2"))
.findFirst()
.orElseThrow();
// Search only inside this card
String price = keyboard.findElement(By.cssSelector(".price")).getText();
keyboard.findElement(By.xpath(".//button[@data-action='add']")).click();
assertEquals("$79", price);
// No error banner appeared
assertTrue(driver.findElements(By.cssSelector(".error-banner")).isEmpty());
}
findElements повертає List<WebElement>, тому по ньому можна пройтися стрімом: filter відбирає картку за назвою, findFirst().orElseThrow() падає, якщо такої немає. keyboard.findElement(...) шукає лише в піддереві картки. У XPath .//button крапка означає «від поточного вузла», без неї пошук пішов би від кореня документа.findElements returns a List<WebElement>, so you can stream over it: filter picks the card by title and findFirst().orElseThrow() fails if there is none. keyboard.findElement(...) searches only the card's subtree. In the XPath .//button the dot means "from the current node"; without it the search would start at the document root.Що це. getText() повертає видимий відрендерений текст елемента разом із нащадками — приблизно те, що людина бачить очима. У <input> немає текстових дочірніх вузлів: введене значення живе у DOM-властивості value. Тому для поля вводу getText() дає порожній рядок, а поточне значення читається через getDomProperty("value").
What it is. getText() returns the visible, rendered text of an element and its descendants — roughly what a person sees. An <input> has no text child nodes: whatever was typed lives in the DOM property value. So for an input field getText() gives an empty string, and the current value is read with getDomProperty("value").
Навіщо. У Selenium 4 є три різні методи, і плутанина між ними дає хибні асерти. getDomProperty читає живу властивість, яку змінює користувач і JavaScript. getDomAttribute читає атрибут із розмітки, тобто початкове значення. Старий getAttribute — «найкраща здогадка»: спершу пробує властивість, потім атрибут, тож для value теж поверне поточний текст.
Why it matters. Selenium 4 has three different methods, and mixing them up produces wrong assertions. getDomProperty reads the live property that the user and JavaScript change. getDomAttribute reads the attribute from the markup, which is the initial value. The older getAttribute is a best guess: it tries the property first and then the attribute, so for value it also returns the current text.
Приклад. Форма профілю приходить з value="Olena", тест стирає поле і вводить «Oksana». Тепер getDomProperty("value") поверне Oksana, getDomAttribute("value") — досі Olena, а getText() — порожній рядок. Асерт на getText() тут завжди впаде або, гірше, пройде при перевірці на порожнечу.
Example. A profile form arrives with value="Olena", the test clears the field and types Oksana. Now getDomProperty("value") returns Oksana, getDomAttribute("value") still returns Olena, and getText() returns an empty string. An assertion on getText() here either always fails or, worse, passes when you check for emptiness.
Підводні камені. getText() не бачить прихованого тексту: для елемента з display: none він поверне порожній рядок, і це не баг Selenium. Ще він нормалізує пробіли і враховує CSS-перетворення на кшталт text-transform: uppercase, тому очікуване значення в тесті інколи треба писати великими літерами.
Pitfalls. getText() ignores hidden text: an element with display: none gives an empty string, and that is not a Selenium bug. It also normalises whitespace and applies CSS transforms such as text-transform: uppercase, so the expected value in a test sometimes has to be written in capitals.
Уточнення. Можуть спитати про <textarea> і чекбокси. Для textarea поточний текст так само береться з властивості value, а стан чекбокса надійніше перевіряти через isSelected(), ніж читати атрибут checked, який відображає лише початковий стан.
Follow-up. You may be asked about <textarea> and checkboxes. A textarea also exposes its current text through the value property, and a checkbox state is better checked with isSelected() than by reading the checked attribute, which only reflects the initial state.
@Test
void readsCurrentValueOfInput() {
driver.get("https://shop.example.com/profile");
WebElement name = driver.findElement(By.id("first-name"));
name.clear();
name.sendKeys("Oksana");
assertEquals("", name.getText()); // у input немає текстових вузлів
assertEquals("Oksana", name.getDomProperty("value")); // поточне значення
assertEquals("Olena", name.getDomAttribute("value")); // значення з HTML-розмітки
WebElement title = driver.findElement(By.cssSelector("h1.profile-title"));
assertEquals("PROFILE", title.getText()); // видимий текст з урахуванням CSS
}
@Test
void readsCurrentValueOfInput() {
driver.get("https://shop.example.com/profile");
WebElement name = driver.findElement(By.id("first-name"));
name.clear();
name.sendKeys("Oksana");
assertEquals("", name.getText()); // an input has no text nodes
assertEquals("Oksana", name.getDomProperty("value")); // current value
assertEquals("Olena", name.getDomAttribute("value")); // value from the HTML markup
WebElement title = driver.findElement(By.cssSelector("h1.profile-title"));
assertEquals("PROFILE", title.getText()); // visible text after CSS is applied
}
clear() очищає поле, sendKeys друкує текст як клавіатура. getDomProperty("value") звертається до JS-властивості вузла, getDomAttribute("value") — до атрибута з розмітки. assertEquals(expected, actual) з JUnit 5 спершу приймає очікуване значення. Останній асерт показує, що getText() повертає текст уже після text-transform.clear() empties the field and sendKeys types text like a keyboard. getDomProperty("value") reads the node's JS property, getDomAttribute("value") reads the markup attribute. JUnit 5 assertEquals(expected, actual) takes the expected value first. The last assertion shows that getText() returns text after text-transform has been applied.Що це. Три булеві методи WebElement про стан уже знайденого елемента. isDisplayed() каже, чи елемент відрендерений видимим: немає display: none, visibility: hidden, нульового розміру чи прихованого предка. isEnabled() повертає false для контролів форми з атрибутом disabled. isSelected() працює для чекбоксів, радіокнопок і <option>.
What it is. Three boolean WebElement methods about the state of an element you have already found. isDisplayed() tells whether the element renders as visible: no display: none, no visibility: hidden, no zero size and no hidden ancestor. isEnabled() returns false for form controls carrying the disabled attribute. isSelected() works for checkboxes, radio buttons and <option> elements.
Навіщо. Ними перевіряють бізнес-правила інтерфейсу: кнопка «Оплатити» неактивна, поки не прийняті умови; чекбокс розсилки вимкнений за замовчуванням; банер помилки з’являється після невдалого входу. Це прості й читабельні асерти, які не потребують JavaScript.
Why it matters. They check UI business rules: the Pay button stays inactive until the terms are accepted, the newsletter checkbox is off by default, an error banner appears after a failed login. These are short, readable assertions that need no JavaScript.
Приклад. Тест реєстрації перевіряє, що submit.isEnabled() дорівнює false, клікає чекбокс згоди, чекає зміни стану і перевіряє, що кнопка стала активною, а terms.isSelected() — true. Відсутність банера помилки перевіряється через findElements(...).isEmpty(), бо findElement на відсутньому вузлі кине виняток ще до виклику isDisplayed().
Example. A sign-up test asserts that submit.isEnabled() is false, clicks the consent checkbox, waits for the state change and asserts that the button became active and terms.isSelected() is true. The absence of the error banner is checked with findElements(...).isEmpty(), because findElement on a missing node throws before isDisplayed() is ever called.
Підводні камені. isDisplayed() не знає, чи елемент перекритий іншим шаром і чи він у зоні прокрутки: видимий за правилами CSS елемент під модальним вікном усе одно дасть true. isEnabled() не бачить «м’якої» неактивності через aria-disabled="true" чи клас на кшталт .btn-disabled — для кастомних кнопок треба перевіряти ці атрибути окремо.
Pitfalls. isDisplayed() knows nothing about another layer covering the element or about the scroll area: an element that is visible by CSS rules but sits under a modal still returns true. isEnabled() does not see soft inactivity through aria-disabled="true" or a class such as .btn-disabled, so custom buttons need those attributes checked separately.
Уточнення. Часто питають, чи можна одразу після дії робити assertTrue(el.isDisplayed()). Краще ні: стан змінюється асинхронно, тому спершу явне очікування, наприклад ExpectedConditions.elementToBeSelected або visibilityOf, а вже потім асерт.
Follow-up. A common question is whether you can call assertTrue(el.isDisplayed()) straight after an action. Better not: state changes asynchronously, so wait explicitly first, for example with ExpectedConditions.elementToBeSelected or visibilityOf, and assert afterwards.
@Test
void payButtonUnlocksAfterConsent() {
WebElement submit = driver.findElement(By.cssSelector("[data-testid='submit']"));
WebElement terms = driver.findElement(By.id("terms"));
assertFalse(submit.isEnabled()); // атрибут disabled на кнопці
assertFalse(terms.isSelected()); // чекбокс вимкнений за замовчуванням
terms.click();
wait.until(ExpectedConditions.elementToBeSelected(terms));
wait.until(ExpectedConditions.elementToBeClickable(submit));
// відсутність елемента перевіряємо списком, а не isDisplayed()
assertTrue(driver.findElements(By.cssSelector(".error-banner")).isEmpty());
// кастомна кнопка: isEnabled() не бачить aria-disabled
WebElement promo = driver.findElement(By.cssSelector("[data-testid='apply-promo']"));
assertEquals("true", promo.getDomAttribute("aria-disabled"));
}
@Test
void payButtonUnlocksAfterConsent() {
WebElement submit = driver.findElement(By.cssSelector("[data-testid='submit']"));
WebElement terms = driver.findElement(By.id("terms"));
assertFalse(submit.isEnabled()); // disabled attribute on the button
assertFalse(terms.isSelected()); // checkbox is off by default
terms.click();
wait.until(ExpectedConditions.elementToBeSelected(terms));
wait.until(ExpectedConditions.elementToBeClickable(submit));
// check absence with a list, not with isDisplayed()
assertTrue(driver.findElements(By.cssSelector(".error-banner")).isEmpty());
// custom button: isEnabled() does not see aria-disabled
WebElement promo = driver.findElement(By.cssSelector("[data-testid='apply-promo']"));
assertEquals("true", promo.getDomAttribute("aria-disabled"));
}
wait — це заздалегідь створений WebDriverWait. elementToBeSelected чекає, поки isSelected() стане true, elementToBeClickable — поки елемент видимий і активний. findElements повертає список, який буває порожнім, тому годиться для перевірки відсутності. getDomAttribute читає атрибут як рядок або повертає null, якщо його немає.wait is a WebDriverWait created beforehand. elementToBeSelected waits until isSelected() becomes true, elementToBeClickable until the element is visible and enabled. findElements returns a list that may be empty, which makes it right for checking absence. getDomAttribute reads the attribute as a string or returns null when it is missing.Що це. org.openqa.selenium.support.ui.Select — обгортка над нативним HTML-елементом <select>. Вона вміє обирати опцію за видимим текстом, атрибутом value або індексом, повертати обрані опції і для списків з атрибутом multiple знімати вибір. Конструктор приймає WebElement і кидає UnexpectedTagNameException, якщо це не <select>.
What it is. org.openqa.selenium.support.ui.Select wraps a native HTML <select> element. It picks an option by visible text, by its value attribute or by index, returns the selected options, and can deselect in lists that carry the multiple attribute. The constructor takes a WebElement and throws UnexpectedTagNameException when it is not a <select>.
Навіщо. Нативний список браузер малює сам, і клікати по опціях у ньому незручно та платформозалежно. Select ховає цю різницю і дає читабельний код. Найстабільніший варіант зазвичай selectByValue: значення — це технічний контракт із бекендом, а видимий текст змінюється з перекладами.
Why it matters. The browser draws a native list itself, and clicking options inside it is awkward and platform-dependent. Select hides that difference and keeps the code readable. The most stable choice is usually selectByValue: the value is a technical contract with the backend, while visible text changes with translations.
Приклад. На сторінці доставки тест обирає країну selectByValue("UA"), а потім перевіряє, що getFirstSelectedOption().getText() дорівнює «Україна» — так перевіряється і збереження, і те, що бачить користувач. Для фільтра з множинним вибором isMultiple() повертає true, і можна викликати deselectAll().
Example. On a delivery page the test picks the country with selectByValue("UA") and then asserts that getFirstSelectedOption().getText() equals Ukraine, which covers both what gets saved and what the user sees. For a multi-select filter isMultiple() returns true, and deselectAll() is available.
Підводні камені. Більшість сучасних UI-бібліотек малюють дропдаун із div і li, а справжнього <select> там немає або він прихований. Select тоді або впаде на конструкторі, або змінить прихований елемент без подій, на які реагує фреймворк. Такий список відкривають кліком по тригеру і клікають потрібну опцію, чекаючи її появи. deselectAll() на звичайному одиночному списку кидає UnsupportedOperationException.
Pitfalls. Most modern UI libraries draw a dropdown out of div and li elements, with no real <select> or only a hidden one. Select then either fails in the constructor or changes the hidden element without the events the framework listens to. Such a list is opened by clicking its trigger, then the option is clicked after waiting for it to appear. deselectAll() on an ordinary single-choice list throws UnsupportedOperationException.
Уточнення. Інколи питають про індекс. selectByIndex найкрихкіший: додали опцію «Оберіть країну» на початок — і тест тихо обирає не те. Використовуйте його лише там, де порядок і є вимогою.
Follow-up. Index sometimes comes up. selectByIndex is the most brittle: someone adds a Choose a country entry at the top and the test quietly selects the wrong option. Use it only where the order itself is the requirement.
// нативний <select id="country">
Select country = new Select(driver.findElement(By.id("country")));
country.selectByValue("UA"); // стабільний технічний ключ
assertEquals("Україна", country.getFirstSelectedOption().getText());
Select tags = new Select(driver.findElement(By.id("tags")));
if (tags.isMultiple()) {
tags.deselectAll();
tags.selectByVisibleText("Sale");
tags.selectByVisibleText("New");
}
assertEquals(2, tags.getAllSelectedOptions().size());
// кастомний дропдаун без <select>: клік по тригеру, потім по опції
driver.findElement(By.cssSelector("[data-testid='city-trigger']")).click();
wait.until(ExpectedConditions.elementToBeClickable(
By.cssSelector("[role='option'][data-value='kyiv']"))).click();
// native <select id="country">
Select country = new Select(driver.findElement(By.id("country")));
country.selectByValue("UA"); // stable technical key
assertEquals("Ukraine", country.getFirstSelectedOption().getText());
Select tags = new Select(driver.findElement(By.id("tags")));
if (tags.isMultiple()) {
tags.deselectAll();
tags.selectByVisibleText("Sale");
tags.selectByVisibleText("New");
}
assertEquals(2, tags.getAllSelectedOptions().size());
// custom dropdown without <select>: click the trigger, then the option
driver.findElement(By.cssSelector("[data-testid='city-trigger']")).click();
wait.until(ExpectedConditions.elementToBeClickable(
By.cssSelector("[role='option'][data-value='kyiv']"))).click();
new Select(element) перевіряє тег у конструкторі. selectByValue, selectByVisibleText і selectByIndex — три способи вибору. getFirstSelectedOption() повертає WebElement опції, getAllSelectedOptions() — список. wait.until(...) повертає елемент, коли умова виконана, тому .click() можна викликати ланцюжком.new Select(element) checks the tag in its constructor. selectByValue, selectByVisibleText and selectByIndex are the three ways to choose. getFirstSelectedOption() returns the option as a WebElement, getAllSelectedOptions() returns a list. wait.until(...) returns the element once the condition holds, so .click() can be chained.Що це. Actions — будівник низькорівневих дій користувача: рух миші, натискання і відпускання кнопок, клавіш, прокрутка колесом. Кроки накопичуються в ланцюжку і відправляються драйверу одним W3C-запитом Perform Actions лише після виклику perform(). Так відтворюються жести, яких немає у звичайному click() чи sendKeys().
What it is. Actions builds low-level user input: mouse movement, pressing and releasing buttons and keys, wheel scrolling. The steps pile up in a chain and are sent to the driver as one W3C Perform Actions request only when perform() is called. That is how you reproduce gestures a plain click() or sendKeys() cannot express.
Навіщо. Меню, що розкривається при наведенні, перетягування карток на канбан-дошці, виділення тексту з Shift, гарячі клавіші Ctrl+A, контекстне меню правою кнопкою, подвійний клік — усе це Actions. У Selenium 4.2 з’явилися дії колеса, зокрема scrollToElement, яка прокручує сторінку без JavaScript.
Why it matters. A menu that opens on hover, dragging cards across a kanban board, selecting text with Shift, shortcuts like Ctrl+A, a right-click context menu, a double click — all of that is Actions. Selenium 4.2 added wheel actions, including scrollToElement, which scrolls the page without JavaScript.
Приклад. Щоб дістатися пункту «Налаштування» в меню профілю, тест викликає moveToElement(avatar), чекає появи пункту і клікає його. Для канбану — dragAndDrop(card, doneColumn) або повільніша послідовність clickAndHold, moveToElement, release, яку легше налагоджувати.
Example. To reach Settings in a profile menu the test calls moveToElement(avatar), waits for the item to show up and clicks it. For a kanban board it uses dragAndDrop(card, doneColumn), or the slower clickAndHold, moveToElement, release sequence, which is easier to debug.
Підводні камені. Найчастіша помилка — забутий perform(): ланцюжок збирається, але нічого не відбувається, і тест падає десь далі. Друга — Ctrl на macOS: там потрібен Keys.COMMAND, тож клавішу-модифікатор варто обирати за ОС. Третя — HTML5 drag and drop через атрибут draggable у Chromium часто не спрацьовує через dragAndDrop, бо синтетичні рухи миші не запускають події dragstart і drop; тоді допомагає перевірка через API або бібліотека самого застосунку.
Pitfalls. The most common mistake is a forgotten perform(): the chain is built, nothing happens, and the test fails somewhere later. The second is Ctrl on macOS, where you need Keys.COMMAND, so pick the modifier by operating system. The third is HTML5 drag and drop through the draggable attribute: in Chromium dragAndDrop often does nothing, because synthetic mouse moves do not fire dragstart and drop; checking through the API or using the app's own library helps there.
Уточнення. Можуть спитати, чим Actions.sendKeys відрізняється від WebElement.sendKeys. Перший друкує в елемент, що зараз у фокусі, імітуючи клавіатуру, другий спершу фокусує конкретний елемент. Для звичайних полів вистачає другого.
Follow-up. You may be asked how Actions.sendKeys differs from WebElement.sendKeys. The first types into whatever element has focus, imitating the keyboard; the second focuses a specific element first. For ordinary fields the second is enough.
Actions actions = new Actions(driver);
// hover: меню профілю розкривається при наведенні
WebElement avatar = driver.findElement(By.cssSelector("[data-testid='avatar']"));
actions.moveToElement(avatar).perform();
wait.until(ExpectedConditions.elementToBeClickable(By.linkText("Settings"))).click();
// перетягування картки в колонку Done
WebElement card = driver.findElement(By.id("task-42"));
WebElement done = driver.findElement(By.id("column-done"));
actions.clickAndHold(card).moveToElement(done).release().perform();
// Ctrl+A / Cmd+A, залежно від ОС
Keys mod = Platform.getCurrent().is(Platform.MAC) ? Keys.COMMAND : Keys.CONTROL;
WebElement editor = driver.findElement(By.id("notes"));
actions.click(editor).keyDown(mod).sendKeys("a").keyUp(mod).sendKeys(Keys.DELETE).perform();
// прокрутка колесом до футера (Selenium 4.2+)
actions.scrollToElement(driver.findElement(By.tagName("footer"))).perform();
Actions actions = new Actions(driver);
// hover: the profile menu opens on mouse over
WebElement avatar = driver.findElement(By.cssSelector("[data-testid='avatar']"));
actions.moveToElement(avatar).perform();
wait.until(ExpectedConditions.elementToBeClickable(By.linkText("Settings"))).click();
// drag a card into the Done column
WebElement card = driver.findElement(By.id("task-42"));
WebElement done = driver.findElement(By.id("column-done"));
actions.clickAndHold(card).moveToElement(done).release().perform();
// Ctrl+A / Cmd+A depending on the OS
Keys mod = Platform.getCurrent().is(Platform.MAC) ? Keys.COMMAND : Keys.CONTROL;
WebElement editor = driver.findElement(By.id("notes"));
actions.click(editor).keyDown(mod).sendKeys("a").keyUp(mod).sendKeys(Keys.DELETE).perform();
// wheel scroll to the footer (Selenium 4.2+)
actions.scrollToElement(driver.findElement(By.tagName("footer"))).perform();
Actions повертає той самий об’єкт, тому кроки пишуться ланцюжком. perform() наприкінці відправляє накопичені дії й очищає ланцюжок. keyDown і keyUp тримають модифікатор натиснутим між ними. Platform.getCurrent() повертає ОС, де запущено тест, — для віддаленого браузера на Grid ОС треба брати з капабіліті.Actions method returns the same object, so the steps are chained. perform() at the end sends the collected actions and clears the chain. keyDown and keyUp keep the modifier pressed between them. Platform.getCurrent() returns the OS the test runs on; for a remote browser on Grid take the OS from the capabilities instead.Що це. JavascriptExecutor — інтерфейс драйвера з методами executeScript і executeAsyncScript, які виконують JavaScript у контексті поточної сторінки чи фрейму. Аргументи з Java доступні як arguments[0], arguments[1], а WebElement передається як справжній DOM-вузол. Результат через return повертається в Java: числа як Long або Double, рядки, булеві, елементи, списки і мапи.
What it is. JavascriptExecutor is a driver interface with executeScript and executeAsyncScript, which run JavaScript in the context of the current page or frame. Java arguments are available as arguments[0], arguments[1], and a WebElement arrives as a real DOM node. Whatever the script returns comes back to Java: numbers as Long or Double, strings, booleans, elements, lists and maps.
Навіщо. Доречні сценарії — читати стан, якого немає в WebDriver API: document.readyState, значення з localStorage, прапорець застосунку на кшталт window.appReady, розмір канви, прокрутку контейнера. Також підготовка оточення: очистити сховище, встановити фіча-флаг, прокрутити всередині віртуалізованого списку.
Why it matters. The appropriate cases are reading state that the WebDriver API does not expose: document.readyState, a value in localStorage, an app flag such as window.appReady, canvas size, the scroll position of a container. It also helps prepare the environment: clearing storage, setting a feature flag, scrolling inside a virtualised list.
Приклад. Тест кошика перевіряє, що після додавання товару в localStorage з’явився ключ cart з однією позицією. WebDriver такого читати не вміє, а один executeScript повертає рядок JSON, який далі парситься й перевіряється. Сама дія «додати в кошик» при цьому виконується справжнім кліком.
Example. A cart test checks that after adding a product localStorage holds a cart key with one line item. WebDriver cannot read that, while one executeScript returns a JSON string that is then parsed and asserted. The add-to-cart action itself is still done with a real click.
Підводні камені. Зловживання — робити через JS те, що має робити користувач: записувати value напряму чи ставити checked = true. React і Vue слухають події input і change, тому стан компонента не зміниться, форма відправить старе значення, а тест матиме хибну картину. Ще одна пастка — повертати з JS великі структури: кожен виклик — це мережевий запит і серіалізація.
Pitfalls. Abuse means doing through JS what the user is supposed to do: writing value directly or setting checked = true. React and Vue listen for input and change events, so the component state does not change, the form submits the old value, and the test paints a false picture. Another trap is returning large structures from JS: every call is a network request plus serialisation.
Уточнення. Питають і про executeAsyncScript. Там останній аргумент — колбек, який скрипт має викликати, інакше спрацює таймаут scriptTimeout з driver.manage().timeouts().
Follow-up. executeAsyncScript also comes up. Its last argument is a callback the script must call, otherwise the scriptTimeout from driver.manage().timeouts() fires.
JavascriptExecutor js = (JavascriptExecutor) driver;
// дія — справжнім кліком, як у користувача
driver.findElement(By.cssSelector("[data-testid='add-to-cart']")).click();
// перевірка стану, недоступного в WebDriver API
String cart = (String) js.executeScript("return window.localStorage.getItem('cart');");
assertTrue(cart.contains("\"qty\":1"));
Long scrollTop = (Long) js.executeScript(
"return Math.round(arguments[0].scrollTop);",
driver.findElement(By.id("order-list")));
// антиприклад: поле змінено, але подія input не спрацювала
WebElement email = driver.findElement(By.id("email"));
js.executeScript("arguments[0].value = arguments[1];", email, "qa@example.com");
// форма все одно відправить старе значення з React-стану
JavascriptExecutor js = (JavascriptExecutor) driver;
// the action is a real click, like a user would do
driver.findElement(By.cssSelector("[data-testid='add-to-cart']")).click();
// checking state that the WebDriver API does not expose
String cart = (String) js.executeScript("return window.localStorage.getItem('cart');");
assertTrue(cart.contains("\"qty\":1"));
Long scrollTop = (Long) js.executeScript(
"return Math.round(arguments[0].scrollTop);",
driver.findElement(By.id("order-list")));
// anti-example: the field changes but no input event fires
WebElement email = driver.findElement(By.id("email"));
js.executeScript("arguments[0].value = arguments[1];", email, "qa@example.com");
// the form still submits the old value from React state
JavascriptExecutor, бо цей інтерфейс реалізують усі стандартні драйвери. return у скрипті обов’язковий, інакше Java отримає null. Цілі числа приходять як Long, тому Math.round гарантує тип. Аргументи після рядка скрипту потрапляють у масив arguments у тому ж порядку.JavascriptExecutor, since every standard driver implements that interface. return inside the script is required, otherwise Java gets null. Whole numbers arrive as Long, so Math.round pins the type. Arguments after the script string land in the arguments array in the same order.Що це. Звичайний click() у WebDriver прокручує елемент у видиму зону, перевіряє, що саме він отримає клік у своїй центральній точці, і відтворює повну послідовність подій миші. JS-клік arguments[0].click() просто генерує подію click на вузлі, пропускаючи всі ці перевірки. Він спрацює навіть на елементі, який перекритий, поза екраном чи нульового розміру.
What it is. A normal WebDriver click() scrolls the element into view, checks that this very element would receive the click at its centre point, and plays the full sequence of mouse events. A JS click, arguments[0].click(), just dispatches a click event on the node and skips all those checks. It works even on an element that is covered, off screen or zero-sized.
Навіщо. Перевірки WebDriver — це частина тесту: вони моделюють, чи зможе людина натиснути кнопку. Якщо поверх «Оформити замовлення» висить банер cookie, користувач кнопку не натисне, і це реальний дефект. ElementClickInterceptedException з текстом «Other element would receive the click» — корисний сигнал, який прямо називає елемент-перекривач.
Why it matters. WebDriver checks are part of the test: they model whether a person could press the button. If a cookie banner hangs over Place order, the user cannot press it, and that is a real defect. ElementClickInterceptedException with the text Other element would receive the click is a useful signal that names the covering element outright.
Приклад. Після релізу тести почали падати на кроці оплати з цим винятком. Хтось замінив клік на JS — тести позеленіли. За тиждень прийшли скарги: на мобільних ширинах банер згоди перекривав кнопку без можливості закрити. Правильна реакція на виняток — прочитати, хто перехоплює клік, і вирішити, це баг продукту чи стан тесту, який треба підготувати.
Example. After a release the tests began failing at the payment step with this exception. Someone replaced the click with a JS one and the tests went green. A week later complaints arrived: at mobile widths the consent banner covered the button and could not be closed. The right response to the exception is to read which element intercepts the click and decide whether it is a product bug or test state that needs preparing.
Підводні камені. Якщо перекривач очікуваний — спінер чи анімація модалки, — треба чекати його зникнення через invisibilityOfElementLocated. Якщо це банер згоди, його закривають один раз у підготовці або виставляють cookie згоди до відкриття сторінки. Також JS-клік не надсилає pointerdown і mousedown, тож компоненти, які слухають саме їх, поводяться інакше, ніж у людини.
Pitfalls. When the cover is expected — a spinner or a modal animation — wait for it to go away with invisibilityOfElementLocated. When it is a consent banner, close it once during setup or set the consent cookie before opening the page. A JS click also sends no pointerdown or mousedown, so components listening for those behave differently from a real user.
Уточнення. Чи буває JS-клік виправданим? Рідко: наприклад, для прихованого технічного елемента, з яким людина не взаємодіє напряму. Але таке місце варто позначити коментарем із причиною, щоб його не копіювали як «фікс» для нестабільних кліків.
Follow-up. Is a JS click ever justified? Rarely: for a hidden technical element a person never interacts with directly, for instance. Even then mark the spot with a comment stating the reason, so nobody copies it as a fix for unstable clicks.
By placeOrder = By.cssSelector("[data-testid='place-order']");
// погано: тест зелений, хоча людина кнопку не натисне
// ((JavascriptExecutor) driver).executeScript("arguments[0].click();", driver.findElement(placeOrder));
// добре: прибрати очікуваний перекривач у підготовці тесту
driver.manage().addCookie(new Cookie("cookie_consent", "accepted"));
driver.navigate().refresh();
wait.until(ExpectedConditions.invisibilityOfElementLocated(By.cssSelector(".spinner")));
try {
driver.findElement(placeOrder).click();
} catch (ElementClickInterceptedException e) {
// повідомлення містить: Other element would receive the click: <div class="cookie-banner">
fail("Place order is covered by another element: " + e.getMessage());
}
By placeOrder = By.cssSelector("[data-testid='place-order']");
// bad: the test is green although a person cannot press the button
// ((JavascriptExecutor) driver).executeScript("arguments[0].click();", driver.findElement(placeOrder));
// good: remove the expected cover while preparing the test
driver.manage().addCookie(new Cookie("cookie_consent", "accepted"));
driver.navigate().refresh();
wait.until(ExpectedConditions.invisibilityOfElementLocated(By.cssSelector(".spinner")));
try {
driver.findElement(placeOrder).click();
} catch (ElementClickInterceptedException e) {
// the message includes: Other element would receive the click: <div class="cookie-banner">
fail("Place order is covered by another element: " + e.getMessage());
}
executeScript. addCookie працює лише на вже відкритому домені, тому після нього refresh() перечитує сторінку з cookie згоди. invisibilityOfElementLocated повертає true, коли елемент зник або його немає в DOM. fail(...) з JUnit 5 валить тест з поясненням, куди підставлено текст винятку.executeScript. addCookie only works on a domain that is already open, so refresh() afterwards reloads the page with the consent cookie. invisibilityOfElementLocated returns true once the element is hidden or gone from the DOM. JUnit 5 fail(...) fails the test with an explanation that includes the exception text.Що це. Thread.sleep(5000) — фіксована пауза: потік просто спить п’ять секунд, нічого не знаючи про стан сторінки. Він не перевіряє, чи з’явилась кнопка, чи зник спінер, чи прийшла відповідь від API. Пауза або задовга, або закоротка — влучити точно неможливо.
What it is. Thread.sleep(5000) is a fixed pause: the thread sleeps for five seconds and knows nothing about the page. It does not check whether the button appeared, the spinner went away or the API answered. The pause is always either too long or too short, and you cannot hit the right value.
Навіщо. Якщо сторінка готова за 300 мс, тест все одно чекає 5 секунд; помножте на сотні кроків — і прогін замість 10 хвилин триває годину. Якщо ж на CI сервер відповів за 6 секунд, пауза закінчилась раніше, і тест падає з NoSuchElementException. Так народжуються flaky-тести, які «лікують», збільшуючи число, і робиться лише гірше.
Why it matters. If the page is ready in 300 ms, the test still waits 5 seconds; multiply that by hundreds of steps and a 10-minute run takes an hour. If the CI server needs 6 seconds, the pause ends too early and the test fails with NoSuchElementException. That is how flaky tests are born, and bumping the number only makes the suite slower without making it reliable.
Приклад. Після кліку «Зберегти» замість сну чекаємо на конкретну подію: wait.until(ExpectedConditions.visibilityOfElementLocated(By.cssSelector(".toast-success"))). WebDriverWait опитує умову кожні 500 мс і повертає керування, щойно вона виконалась, тому швидке оточення проходить швидко, а повільне отримує запас до таймауту.
Example. After clicking Save, instead of sleeping we wait for a concrete event: wait.until(ExpectedConditions.visibilityOfElementLocated(By.cssSelector(".toast-success"))). WebDriverWait polls the condition every 500 ms and returns as soon as it holds, so a fast environment finishes quickly while a slow one still gets headroom up to the timeout.
Підводні камені. Сон часто ховається в хелперах на кшталт sleepAbit() або в «тимчасовому» фіксі, що живе роками. Інша пастка — замінити сон на явне очікування неправильної умови: чекати на присутність кнопки, коли насправді треба дочекатись, поки зникне оверлей. Очікування має описувати саме той стан, від якого залежить наступний крок.
Pitfalls. Sleeps like to hide in helpers such as sleepAbit() or in a temporary fix that survives for years. Another trap is swapping the sleep for an explicit wait on the wrong condition: waiting for the button to be present when the real blocker is an overlay that has not disappeared yet. The wait must describe exactly the state the next step depends on.
Уточнення. Чи буває сон виправданим? Хіба в разовому діагностичному скрипті або коли стан узагалі неможливо спостерігати, наприклад анімація без жодної зміни в DOM. Навіть тоді краще винести його в один метод з поясненням і тікетом, ніж розкидати по тестах.
Follow-up. Is a sleep ever acceptable? Perhaps in a one-off debugging script, or when the state truly cannot be observed, such as an animation that changes nothing in the DOM. Even then, keep it in a single method with a comment and a ticket rather than scattering it across tests.
@Test
void savesProfile() {
driver.findElement(By.id("save")).click();
// Погано: завжди 5 с, і все одно може бути замало
// Thread.sleep(5000);
// Добре: повертається, щойно тост з'явився, максимум 10 с
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
WebElement toast = wait.until(
ExpectedConditions.visibilityOfElementLocated(By.cssSelector(".toast-success")));
assertEquals("Profile saved", toast.getText());
}
@Test
void savesProfile() {
driver.findElement(By.id("save")).click();
// Bad: always 5 s, and it may still be too short
// Thread.sleep(5000);
// Good: returns as soon as the toast is visible, 10 s at most
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
WebElement toast = wait.until(
ExpectedConditions.visibilityOfElementLocated(By.cssSelector(".toast-success")));
assertEquals("Profile saved", toast.getText());
}
new WebDriverWait(driver, Duration.ofSeconds(10)) — конструктор Selenium 4 з Duration; старий варіант з числом секунд застарів. wait.until(...) повторює умову, доки вона не поверне не-null значення, і віддає це значення — тут знайдений WebElement. Закоментований Thread.sleep ще й кидає перевірений InterruptedException, що змушує засмічувати сигнатури тестів.new WebDriverWait(driver, Duration.ofSeconds(10)) is the Selenium 4 constructor taking a Duration; the old seconds-as-number variant is deprecated. wait.until(...) retries the condition until it returns a non-null value and hands that value back, here the found WebElement. The commented-out Thread.sleep also throws the checked InterruptedException, which clutters test signatures.Що це. driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(10)) — налаштування всієї сесії: кожен findElement і findElements драйвер повторює до 10 секунд, поки не знайде хоча б один елемент. Очікування живе на боці драйвера, а не в Java-коді, і стосується лише пошуку, а не видимості чи клікабельності.
What it is. driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(10)) is a session-wide setting: every findElement and findElements is retried by the driver for up to 10 seconds until at least one element is found. The wait lives on the driver side, not in Java code, and it covers only lookup, not visibility or clickability.
Навіщо. Implicit wait здається зручним: одна строка — і NoSuchElement зникає. Але він не вміє чекати на видимість, текст чи зникнення елемента, а перевірка відсутності стає дорогою: findElements(...).isEmpty() щоразу спалює всі 10 секунд, перш ніж повернути порожній список.
Why it matters. An implicit wait feels convenient: one line and NoSuchElement disappears. But it cannot wait for visibility, text or disappearance, and checking absence becomes expensive: findElements(...).isEmpty() burns the full 10 seconds every time before returning an empty list.
Приклад. Implicit 10 с плюс WebDriverWait на 15 с з умовою visibilityOfElementLocated. Кожне опитування явного очікування саме викликає findElement, який може блокуватись до 10 с. Якщо чергова спроба стартувала на 14-й секунді, вона завершиться лише на 24-й, і таймаут спрацює приблизно там. Документація Selenium наводить саме такий приклад: 10 + 15 може дати падіння через 20 секунд, тому прямо радить не змішувати.
Example. Implicit 10 s plus a 15 s WebDriverWait on visibilityOfElementLocated. Each poll of the explicit wait calls findElement itself, and that call can block for up to 10 s. If a poll starts at second 14, it only returns at second 24, and the timeout fires around then. The Selenium documentation uses this exact example, noting that 10 plus 15 can time out after 20 seconds, and plainly advises against mixing.
Підводні камені. invisibilityOfElementLocated з увімкненим implicit wait повертає true лише після того, як пошук вичерпає свої 10 секунд, — тест «чекає, що спінер зник» довше, ніж сам спінер існував. Ще гірше, коли хтось вмикає implicit wait у базовому класі, а інший автор про це не знає і дивується розкиду часу.
Pitfalls. With an implicit wait on, invisibilityOfElementLocated returns true only after the lookup has used up its 10 seconds, so a test waiting for a spinner to vanish waits longer than the spinner ever existed. It gets worse when someone enables the implicit wait in a base class and another author, unaware of it, is puzzled by the timing spread.
Уточнення. Практичне правило: implicit wait дорівнює нулю (це і є значення за замовчуванням), а всі очікування явні і локальні для кроку. Якщо легасі-фреймворк тримається на implicit wait, міграцію роблять поступово: обнуляють його і замінюють пошуки на явні очікування в Page Object.
Follow-up. The working rule: keep the implicit wait at zero, which is already the default, and make every wait explicit and local to its step. If a legacy framework relies on an implicit wait, migrate gradually: set it to zero and replace lookups in page objects with explicit waits.
// Налаштування на всю сесію: кожен пошук чекає до 10 с
driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(10));
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(15));
long start = System.currentTimeMillis();
try {
// кожне опитування саме може висіти до 10 с усередині findElement
wait.until(ExpectedConditions.visibilityOfElementLocated(By.id("report")));
} catch (TimeoutException e) {
// тут часто буде 20-25 с, а не 15
System.out.println("Waited ms: " + (System.currentTimeMillis() - start));
}
// Перевірка відсутності «коштує» повний implicit wait
boolean gone = driver.findElements(By.cssSelector(".spinner")).isEmpty();
// Рішення: implicit = 0, лише явні очікування
driver.manage().timeouts().implicitlyWait(Duration.ZERO);
// Session-wide setting: every lookup waits up to 10 s
driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(10));
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(15));
long start = System.currentTimeMillis();
try {
// every poll can itself hang for up to 10 s inside findElement
wait.until(ExpectedConditions.visibilityOfElementLocated(By.id("report")));
} catch (TimeoutException e) {
// this often prints 20-25 s, not 15
System.out.println("Waited ms: " + (System.currentTimeMillis() - start));
}
// Checking absence costs the full implicit wait
boolean gone = driver.findElements(By.cssSelector(".spinner")).isEmpty();
// The fix: implicit = 0, explicit waits only
driver.manage().timeouts().implicitlyWait(Duration.ZERO);
manage().timeouts().implicitlyWait(...) приймає Duration у Selenium 4 і діє до кінця сесії або до наступного виклику. Duration.ZERO вимикає implicit wait. findElements при порожньому результаті не кидає виняток, але повертає список лише після спливання implicit wait. Блок try/catch з System.currentTimeMillis() тут лише для того, щоб побачити реальну тривалість.manage().timeouts().implicitlyWait(...) takes a Duration in Selenium 4 and stays in force for the rest of the session or until the next call. Duration.ZERO turns the implicit wait off. findElements never throws on an empty result, but it returns the list only after the implicit wait has expired. The try/catch with System.currentTimeMillis() is there only to show the real duration.Що це. WebDriverWait — явне очікування: воно викликає умову кожні 500 мс (за замовчуванням), доки та не поверне значення, відмінне від null і false, або доки не мине таймаут. ExpectedConditions — набір готових умов: visibilityOfElementLocated, elementToBeClickable, textToBePresentInElementLocated, urlContains, numberOfElementsToBe тощо.
What it is. WebDriverWait is an explicit wait: it calls a condition every 500 ms by default until the condition returns something other than null or false, or until the timeout runs out. ExpectedConditions is a set of ready-made conditions: visibilityOfElementLocated, elementToBeClickable, textToBePresentInElementLocated, urlContains, numberOfElementsToBe and more.
Навіщо. Явне очікування прив’язане до конкретного кроку і конкретного стану, тому тест чекає рівно стільки, скільки треба. Коли готової умови бракує, пишуть лямбду: ExpectedCondition — це по суті функція від WebDriver, тож wait.until(d -> ...) приймає будь-який вираз.
Why it matters. An explicit wait is tied to one step and one state, so the test waits exactly as long as needed. When no ready condition fits, you write a lambda: an ExpectedCondition is essentially a function of WebDriver, so wait.until(d -> ...) accepts any expression.
Приклад. Таблиця замовлень підвантажується порціями, і нам потрібно дочекатись рівно 20 рядків. Готової умови «рівно N і текст першого рядка такий-то» немає, тому пишемо лямбду, яка повертає Boolean. Якщо умова так і не справдилась, until кидає TimeoutException з текстом на кшталт «Expected condition failed: waiting for ... (tried for 10 second(s) with 500 milliseconds interval)».
Example. An orders table loads in chunks and we need exactly 20 rows. There is no built-in condition for exactly N rows with a given first-row text, so we write a lambda returning a Boolean. If the condition never becomes true, until throws a TimeoutException with text like: Expected condition failed: waiting for ... (tried for 10 second(s) with 500 milliseconds interval).
Підводні камені. Для лямбди опис у повідомленні буде невиразним, тому додавайте withMessage(...) — у звіті з’явиться зрозуміла причина. Не ловіть TimeoutException, щоб «продовжити тест»: це приховує дефект. Виняток ловлять лише тоді, коли відсутність стану — очікуваний сценарій, або щоб додати контекст і кинути далі. І пам’ятайте, що StaleElementReferenceException за замовчуванням не ігнорується.
Pitfalls. A lambda has no readable description in that message, so add withMessage(...) and the report will state a clear reason. Do not catch TimeoutException just to let the test carry on: that hides a defect. Catch it only when the missing state is a legitimate scenario, or to add context and rethrow. And remember that StaleElementReferenceException is not ignored by default.
Уточнення. Що саме ігнорує WebDriverWait? Конструктор додає NotFoundException, тож NoSuchElementException під час опитування не перериває очікування. Інші винятки можна додати через ignoring(...).
Follow-up. What does WebDriverWait ignore out of the box? Its constructor adds NotFoundException, so a NoSuchElementException during polling does not break the wait. Other exceptions can be added with ignoring(...).
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
// Готова умова: чекаємо, що кнопка видима і активна
wait.until(ExpectedConditions.elementToBeClickable(By.id("load-orders"))).click();
// Власна умова: рівно 20 рядків і перший — потрібне замовлення
wait.withMessage("очікували 20 рядків, першим ORD-1001")
.until(d -> {
List<WebElement> rows = d.findElements(By.cssSelector("table.orders tbody tr"));
return rows.size() == 20 && rows.get(0).getText().startsWith("ORD-1001");
});
// TimeoutException ловимо лише для очікуваного сценарію
try {
wait.until(ExpectedConditions.visibilityOfElementLocated(By.cssSelector(".promo")));
} catch (TimeoutException e) {
// банер показується не всім, це не дефект
}
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
// Built-in condition: wait until the button is visible and enabled
wait.until(ExpectedConditions.elementToBeClickable(By.id("load-orders"))).click();
// Custom condition: exactly 20 rows and the first one is the right order
wait.withMessage("expected 20 rows, first one ORD-1001")
.until(d -> {
List<WebElement> rows = d.findElements(By.cssSelector("table.orders tbody tr"));
return rows.size() == 20 && rows.get(0).getText().startsWith("ORD-1001");
});
// Catch TimeoutException only for a legitimate scenario
try {
wait.until(ExpectedConditions.visibilityOfElementLocated(By.cssSelector(".promo")));
} catch (TimeoutException e) {
// the banner is not shown to every user, not a defect
}
until повертає результат умови: для elementToBeClickable це WebElement, тому одразу можна викликати click(). d -> {...} — лямбда, де d — той самий WebDriver; вона повертає Boolean, і очікування триває, поки результат false. withMessage змінює той самий об’єкт очікування і додає текст до повідомлення TimeoutException.until returns the condition result: for elementToBeClickable that is a WebElement, so click() can be chained. d -> {...} is a lambda where d is the same WebDriver; it returns a Boolean and the wait keeps going while the result is false. withMessage modifies the same wait object and adds its text to the TimeoutException message.Що це. Це три дедалі суворіші умови. presenceOfElementLocated справджується, щойно елемент є в DOM, навіть прихований через display: none. visibilityOfElementLocated вимагає ще й відображення: елемент має бути видимий і мати ненульові розміри. elementToBeClickable додає перевірку isEnabled(), тобто видимий і не заблокований.
What it is. These are three increasingly strict conditions. presenceOfElementLocated holds as soon as the element exists in the DOM, even if display: none hides it. visibilityOfElementLocated also requires the element to be displayed with a non-zero size. elementToBeClickable adds an isEnabled() check, meaning visible and not disabled.
Навіщо. Вибір умови залежить від наступної дії. Щоб прочитати атрибут прихованого поля або переконатись, що рядок додано в DOM, досить присутності. Щоб прочитати текст, який бачить користувач, потрібна видимість, бо getText() прихованого елемента повертає порожній рядок. Перед кліком логічно чекати клікабельність.
Why it matters. The right condition depends on the next action. Presence is enough to read an attribute of a hidden field or to confirm a row was added to the DOM. To read text the user sees you need visibility, because getText() on a hidden element returns an empty string. Before a click it makes sense to wait for clickability.
Приклад. Модальне вікно рендериться в DOM одразу, але показується з анімацією. Умова присутності поверне елемент миттєво, і клік по кнопці всередині впаде з ElementNotInteractableException. З elementToBeClickable тест дочекається, поки кнопка стане видимою і активною, і лише тоді клікне.
Example. A modal is rendered into the DOM immediately but shown with an animation. The presence condition returns the element at once, and a click on a button inside fails with ElementNotInteractableException. With elementToBeClickable the test waits until the button is visible and enabled and only then clicks.
Підводні камені. Назва elementToBeClickable обманлива: вона не перевіряє, чи не перекриває елемент інший шар. Якщо над кнопкою ще висить напівпрозорий оверлей або спінер, умова скаже «готово», а клік завершиться ElementClickInterceptedException. Тоді треба окремо чекати зникнення оверлею через invisibilityOfElementLocated.
Pitfalls. The name elementToBeClickable is misleading: it does not check whether another layer covers the element. If a semi-transparent overlay or spinner still sits above the button, the condition says ready and the click ends with ElementClickInterceptedException. In that case you wait separately for the overlay to disappear with invisibilityOfElementLocated.
Уточнення. Що вважається видимим? Selenium застосовує власний алгоритм відображення: враховує display, visibility, opacity та розміри, але елемент за межами екрана все одно може бути видимим, і перед кліком драйвер сам прокрутить до нього.
Follow-up. What counts as visible? Selenium uses its own displayedness algorithm that looks at display, visibility, opacity and size, yet an element outside the viewport can still be visible, and the driver scrolls to it before clicking.
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
// є в DOM, навіть якщо прихований
WebElement csrf = wait.until(
ExpectedConditions.presenceOfElementLocated(By.name("csrf")));
// видимий: можна читати текст, який бачить користувач
String title = wait.until(
ExpectedConditions.visibilityOfElementLocated(By.cssSelector(".modal h2"))).getText();
// видимий і enabled, але перекриття оверлеєм не перевіряється
wait.until(ExpectedConditions.invisibilityOfElementLocated(By.cssSelector(".overlay")));
wait.until(ExpectedConditions.elementToBeClickable(By.id("confirm"))).click();
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
// in the DOM, even if hidden
WebElement csrf = wait.until(
ExpectedConditions.presenceOfElementLocated(By.name("csrf")));
// visible: safe to read the text the user sees
String title = wait.until(
ExpectedConditions.visibilityOfElementLocated(By.cssSelector(".modal h2"))).getText();
// visible and enabled, but being covered by an overlay is not checked
wait.until(ExpectedConditions.invisibilityOfElementLocated(By.cssSelector(".overlay")));
wait.until(ExpectedConditions.elementToBeClickable(By.id("confirm"))).click();
By і повертають WebElement, тому результат можна одразу використати: .getText() чи .click(). invisibilityOfElementLocated повертає Boolean і справджується, коли елемент прихований або його немає в DOM. Порядок рядків важливий: спершу чекаємо зникнення оверлею, потім клікабельність кнопки.By and return a WebElement, so the result can be used straight away with .getText() or .click(). invisibilityOfElementLocated returns a Boolean and holds when the element is hidden or absent from the DOM. The order of the lines matters: first wait for the overlay to go, then for the button to be clickable.Що це. FluentWait<T> — загальний клас очікування, де все налаштовується явно: withTimeout задає загальний ліміт, pollingEvery — інтервал між спробами, ignoring — винятки, які не переривають очікування, withMessage — текст помилки. WebDriverWait є його підкласом із типом WebDriver, опитуванням кожні 500 мс і вже доданим NotFoundException.
What it is. FluentWait<T> is the generic wait class where everything is set explicitly: withTimeout sets the overall limit, pollingEvery the gap between attempts, ignoring the exceptions that do not break the wait, and withMessage the failure text. WebDriverWait is its subclass typed on WebDriver, polling every 500 ms with NotFoundException already ignored.
Навіщо. FluentWait потрібен, коли дефолти не підходять. Наприклад, віджет курсу валют перемальовується кожну секунду, і посилання на елемент постійно застаріває: тоді треба ігнорувати StaleElementReferenceException. Або стан змінюється за 50–100 мс, і опитування раз на пів секунди додає зайву затримку до кожного кроку.
Why it matters. FluentWait is for cases where the defaults do not fit. For instance, an exchange-rate widget re-renders every second and the element reference keeps going stale, so StaleElementReferenceException must be ignored. Or the state changes within 50 to 100 ms, and polling every half second adds needless delay to each step.
Приклад. Чекаємо, поки лічильник кошика покаже «3», а сам бейдж React перестворює при кожному оновленні. FluentWait з таймаутом 15 с, опитуванням кожні 200 мс, ігноруванням NoSuchElementException і StaleElementReferenceException переживе і момент, коли бейджа ще немає, і момент, коли його замінили новим вузлом.
Example. We wait for the cart counter to read 3, while React recreates the badge on every update. A FluentWait with a 15 s timeout, 200 ms polling and both NoSuchElementException and StaleElementReferenceException ignored survives both the moment the badge is missing and the moment it has been replaced by a new node.
Підводні камені. Надто часте опитування — наприклад, кожні 10 мс — засипає драйвер запитами, особливо через Grid, і робить прогін повільнішим, а не швидшим. Ігнорування надто широкого винятку, скажімо WebDriverException, маскує справжні збої на кшталт впалої сесії: тест мовчки чекає до таймауту замість миттєвого падіння з причиною.
Pitfalls. Polling too often, every 10 ms for example, floods the driver with requests, especially through Grid, and makes the run slower rather than faster. Ignoring an overly broad exception such as WebDriverException masks real failures like a dead session: the test silently waits until the timeout instead of failing at once with the cause.
Уточнення. Оскільки тип узагальнений, FluentWait можна застосувати не лише до драйвера: new FluentWait<>(element) або навіть до шляху файлу, щоб дочекатись завершення завантаження на диск.
Follow-up. Because the type is generic, FluentWait is not limited to the driver: new FluentWait<>(element) works, and so does a file path when you wait for a download to finish on disk.
Wait<WebDriver> wait = new FluentWait<>(driver)
.withTimeout(Duration.ofSeconds(15)) // загальний ліміт
.pollingEvery(Duration.ofMillis(200)) // інтервал між спробами
.ignoring(NoSuchElementException.class) // бейджа ще немає
.ignoring(StaleElementReferenceException.class) // бейдж перестворили
.withMessage("лічильник кошика не показав 3");
// кожна спроба шукає елемент заново, тож старе посилання не заважає
String count = wait.until(d -> {
String text = d.findElement(By.cssSelector("[data-testid=cart-count]")).getText();
return text.equals("3") ? text : null;
});
Wait<WebDriver> wait = new FluentWait<>(driver)
.withTimeout(Duration.ofSeconds(15)) // overall limit
.pollingEvery(Duration.ofMillis(200)) // gap between attempts
.ignoring(NoSuchElementException.class) // badge not rendered yet
.ignoring(StaleElementReferenceException.class) // badge was recreated
.withMessage("cart counter never showed 3");
// every attempt looks the element up again, so a stale reference does no harm
String count = wait.until(d -> {
String text = d.findElement(By.cssSelector("[data-testid=cart-count]")).getText();
return text.equals("3") ? text : null;
});
Wait<WebDriver> — інтерфейс, який реалізують і FluentWait, і WebDriverWait. Методи налаштування повертають той самий об’єкт, тому їх можна ланцюжити. ignoring приймає клас винятку; його можна викликати кілька разів. Лямбда повертає рядок або null: null означає «ще ні», а непорожнє значення завершує очікування і повертається з until.Wait<WebDriver> is the interface that both FluentWait and WebDriverWait implement. The configuration methods return the same object, so they chain. ignoring takes an exception class and can be called several times. The lambda returns a string or null: null means not yet, while a non-null value ends the wait and is returned from until.Що це. driver.get(url) блокується відповідно до pageLoadStrategy: NORMAL (за замовчуванням) чекає, поки document.readyState стане complete, EAGER — стану interactive, NONE повертається одразу. А от клік по посиланню чи кнопці «Далі» нічого такого не гарантує: команда кліку завершується, а перехід ще триває.
What it is. driver.get(url) blocks according to pageLoadStrategy: NORMAL, the default, waits for document.readyState to be complete, EAGER waits for interactive, and NONE returns immediately. A click on a link or a Next button guarantees none of that: the click command finishes while the navigation is still under way.
Навіщо. Якщо одразу після кліку шукати елемент нової сторінки, тест може знайти однойменний елемент ще на старій, отримати його, а потім упасти зі StaleElementReferenceException. Надійна схема — дочекатись ознаки, що ми вже на цільовій сторінці: urlContains, urlToBe, titleIs, а головне — ключовий елемент, який є лише там.
Why it matters. If you look for an element of the new page right after the click, the test may find an element with the same locator still on the old page, grab it and then fail with StaleElementReferenceException. The reliable pattern is to wait for proof that you are on the target page: urlContains, urlToBe, titleIs, and above all a key element that exists only there.
Приклад. Після оплати застосунок переходить на /orders/123/confirmation. Чекаємо urlMatches з регулярним виразом, а потім видимість заголовка підтвердження. URL доводить, що роутер спрацював, заголовок — що компонент намалювався з даними.
Example. After payment the app moves to /orders/123/confirmation. We wait for urlMatches with a regular expression and then for the confirmation heading to be visible. The URL proves the router fired; the heading proves the component rendered with its data.
Підводні камені. У SPA перехід між «сторінками» робиться через history.pushState без перезавантаження, тож document.readyState весь час лишається complete, а дані підтягуються окремими запитами вже після зміни URL. Перевірка readyState через JavascriptExecutor тут нічого не дає. Так само titleIs ненадійний, якщо заголовок вкладки оновлюється із запізненням або однаковий для всіх роутів.
Pitfalls. In a SPA a page change happens through history.pushState without a reload, so document.readyState stays complete the whole time and the data arrives via separate requests after the URL has changed. Checking readyState through JavascriptExecutor tells you nothing there. Likewise titleIs is unreliable when the tab title updates late or is identical for every route.
Уточнення. Коли має сенс EAGER? Коли сторінка довго вантажить сторонні скрипти чи рекламу, які тесту не потрібні: driver.get повернеться раніше, а готовність потрібних елементів тест і так перевіряє явними очікуваннями.
Follow-up. When does EAGER make sense? When a page spends a long time loading third-party scripts or ads the test does not need: driver.get returns earlier, and the test checks the elements it cares about with explicit waits anyway.
// Стратегія завантаження задається до старту сесії
ChromeOptions options = new ChromeOptions();
options.setPageLoadStrategy(PageLoadStrategy.EAGER);
WebDriver driver = new ChromeDriver(options);
driver.get("https://shop.example.com/checkout"); // блокується до interactive
driver.findElement(By.id("pay")).click(); // навігацію не чекає
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(15));
// 1) роутер перейшов на сторінку підтвердження
wait.until(ExpectedConditions.urlMatches("/orders/\d+/confirmation$"));
// 2) компонент намалювався з даними
WebElement heading = wait.until(ExpectedConditions.visibilityOfElementLocated(
By.cssSelector("[data-testid=confirmation-title]")));
// The load strategy is set before the session starts
ChromeOptions options = new ChromeOptions();
options.setPageLoadStrategy(PageLoadStrategy.EAGER);
WebDriver driver = new ChromeDriver(options);
driver.get("https://shop.example.com/checkout"); // blocks until interactive
driver.findElement(By.id("pay")).click(); // does not wait for navigation
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(15));
// 1) the router moved to the confirmation page
wait.until(ExpectedConditions.urlMatches("/orders/\d+/confirmation$"));
// 2) the component rendered with its data
WebElement heading = wait.until(ExpectedConditions.visibilityOfElementLocated(
By.cssSelector("[data-testid=confirmation-title]")));
setPageLoadStrategy(PageLoadStrategy.EAGER) — метод опцій, що стає капабіліті сесії pageLoadStrategy; змінити його посеред сесії не можна. urlMatches приймає регулярний вираз і шукає збіг у поточному URL; подвійний зворотний слеш потрібен, бо в Java-рядку \d записується як дві косі. urlContains та urlToBe працюють з підрядком і точним значенням.setPageLoadStrategy(PageLoadStrategy.EAGER) is an options method that becomes the session capability pageLoadStrategy; it cannot be changed mid-session. urlMatches takes a regular expression and looks for a match in the current URL; the doubled backslash is needed because a Java string writes \d with two slashes. urlContains and urlToBe work with a substring and an exact value.Що це. Шар очікувань — це набір правил і утиліт, через які проходить кожна взаємодія з UI: єдине джерело таймаутів, бібліотека власних умов і заборона на сон та implicit wait. Мета — щоб автор тесту описував бізнес-крок, а не думав, скільки секунд і як саме чекати.
What it is. The wait layer is a set of rules and utilities every UI interaction goes through: one source of timeouts, a library of custom conditions, and a ban on sleeps and implicit waits. The goal is for a test author to describe a business step instead of thinking about how many seconds to wait and how.
Навіщо. У наборі з тисячі тестів розкидані new WebDriverWait(driver, Duration.ofSeconds(30)) з різними числами дають дві біди: частина тестів падає на повільному стенді, а частина чекає по пів хвилини на те, що має статись за секунду. Централізація дозволяє змінити поведінку для оточення одним параметром і міряти, де ми реально чекаємо.
Why it matters. In a suite of a thousand tests, scattered new WebDriverWait(driver, Duration.ofSeconds(30)) calls with assorted numbers cause two problems: some tests fail on a slow environment, others wait half a minute for something that should take one second. Centralising lets you change behaviour per environment with one parameter and measure where the suite actually waits.
Приклад. Клас Timeouts читає значення з системної властивості: 10 с локально, 20 с на спільному staging. Клас Waits надає умови, яких немає в ExpectedConditions: «усі спінери зникли», «кількість рядків перестала змінюватись протягом 500 мс», «кнопка не перекрита». Page Object викликає лише ці методи, а code review і правило в ArchUnit ловлять Thread.sleep та implicitlyWait.
Example. A Timeouts class reads values from a system property: 10 s locally, 20 s on a shared staging. A Waits class offers conditions ExpectedConditions lacks: all spinners gone, row count unchanged for 500 ms, button not covered. Page objects call only these methods, while code review and an ArchUnit rule catch Thread.sleep and implicitlyWait.
Підводні камені. Глобальне збільшення таймауту «щоб не падало» ховає реальну деградацію продукту; краще логувати тривалість кожного очікування і дивитись на 95-й перцентиль по кроках. Ще одна пастка — універсальний waitForPageReady(), що чекає все підряд: він повільний і все одно не знає про конкретний стан. Власні умови мають бути малими і названими за змістом.
Pitfalls. Raising the timeout globally so things stop failing hides real product degradation; it is better to log the duration of every wait and watch the 95th percentile per step. Another trap is a universal waitForPageReady() that waits for everything: it is slow and still knows nothing about the specific state. Custom conditions should be small and named after what they mean.
Уточнення. Що робити з асинхронністю, яку UI не показує? Дати розробникам тестовий хук, наприклад атрибут data-loading на кореневому елементі чи лічильник активних запитів, і чекати саме на нього. Це дешевше, ніж вгадувати за зовнішніми ознаками.
Follow-up. What about asynchronous work the UI does not show? Ask developers for a test hook, such as a data-loading attribute on the root element or a counter of in-flight requests, and wait on that. It is cheaper than guessing from outside signs.
public final class Timeouts {
// -Dui.timeout=20 на staging, 10 за замовчуванням
public static final Duration UI =
Duration.ofSeconds(Long.getLong("ui.timeout", 10));
}
public final class Waits {
private final WebDriverWait wait;
public Waits(WebDriver driver) {
this.wait = new WebDriverWait(driver, Timeouts.UI);
}
// усі спінери зникли з екрана
public void spinnersGone() {
wait.withMessage("спінер не зник")
.until(ExpectedConditions.invisibilityOfElementLocated(By.cssSelector(".spinner")));
}
// кількість рядків не змінювалась два опитування поспіль
public int rowCountStable(By rows) {
int[] last = {-1};
return wait.withMessage("список не стабілізувався").until(d -> {
int now = d.findElements(rows).size();
boolean stable = now == last[0];
last[0] = now;
return stable ? now : null;
});
}
}
public final class Timeouts {
// -Dui.timeout=20 on staging, 10 by default
public static final Duration UI =
Duration.ofSeconds(Long.getLong("ui.timeout", 10));
}
public final class Waits {
private final WebDriverWait wait;
public Waits(WebDriver driver) {
this.wait = new WebDriverWait(driver, Timeouts.UI);
}
// every spinner has left the screen
public void spinnersGone() {
wait.withMessage("spinner did not disappear")
.until(ExpectedConditions.invisibilityOfElementLocated(By.cssSelector(".spinner")));
}
// row count unchanged across two polls in a row
public int rowCountStable(By rows) {
int[] last = {-1};
return wait.withMessage("list never settled").until(d -> {
int now = d.findElements(rows).size();
boolean stable = now == last[0];
last[0] = now;
return stable ? now : null;
});
}
}
Long.getLong("ui.timeout", 10) читає системну властивість JVM, передану через -D, з дефолтом 10. int[] last — масив з одного елемента, бо лямбда не може змінювати звичайну локальну змінну, а вміст масиву — може. Лямбда повертає Integer або null, тож until поверне стабільну кількість рядків. Інтервал стабільності дорівнює кроку опитування, 500 мс.Long.getLong("ui.timeout", 10) reads a JVM system property passed with -D, defaulting to 10. int[] last is a one-element array because a lambda cannot reassign a plain local variable but can change an array element. The lambda returns an Integer or null, so until returns the settled row count. The stability window equals the polling step, 500 ms.Що це. WebElement у Selenium — лише посилання на конкретний DOM-вузол. Якщо фронтенд видалив цей вузол і намалював новий, навіть з тим самим id і текстом, старе посилання «протухає», і будь-яка дія з ним кидає StaleElementReferenceException. Те саме стається після driver.navigate().refresh() або переходу на іншу сторінку.
What it is. A WebElement in Selenium is only a reference to one specific DOM node. If the front end removes that node and renders a new one, even with the same id and text, the old reference goes stale, and any action on it throws StaleElementReferenceException. The same happens after driver.navigate().refresh() or a move to another page.
Навіщо. У React, Vue чи Angular перемальовування — норма: сортування таблиці, фільтр, оновлення після AJAX. Тест, який один раз знайшов елемент і тримає його в полі класу, падає нестабільно — залежно від того, встиг фреймворк перебудувати список чи ні.
Why it matters. In React, Vue or Angular re-rendering is routine: sorting a table, applying a filter, refreshing after an AJAX call. A test that finds an element once and keeps it in a class field fails intermittently, depending on whether the framework managed to rebuild the list before the next command.
Приклад. Знаходимо перший рядок таблиці замовлень, клікаємо сортування за датою, а потім читаємо текст старого рядка — виняток гарантований, бо tbody збудовано заново. Виправлення: зберігати не WebElement, а By, після дії, що перемальовує DOM, дочекатися ExpectedConditions.stalenessOf(oldRow) — тобто підтвердження, що старий вузол зник, — і лише тоді шукати рядок наново.
Example. We grab the first row of the orders table, click sort by date and then read the text of the old row: the exception is guaranteed because the tbody was rebuilt. The fix is to keep a By rather than a WebElement, and after an action that re-renders the DOM wait for ExpectedConditions.stalenessOf(oldRow), which confirms the old node is gone, and only then locate the row again.
Підводні камені. Обгортка «спіймай stale і повтори тричі» маскує справжню проблему: тест може прочитати дані ще до сортування і пройти з неправильним результатом. Кешування через @CacheLookup у PageFactory робить stale майже гарантованим на динамічних сторінках. Також не варто ловити виняток усередині page object і мовчки повертати порожній рядок.
Pitfalls. A wrapper that catches stale and retries three times hides the real problem: the test may read data before the sort finished and pass with the wrong result. Caching with @CacheLookup in PageFactory makes stale references almost certain on dynamic pages. Swallowing the exception inside a page object and returning an empty string is just as harmful.
Уточнення. Питання «чим stale відрізняється від NoSuchElement?» — NoSuchElement означає, що пошук нічого не знайшов, а stale — що знайдений раніше вузол більше не прикріплений до документа.
Follow-up. "How is stale different from NoSuchElement?" NoSuchElement means the search found nothing, while stale means a node found earlier is no longer attached to the document.
By firstRow = By.cssSelector("#orders tbody tr");
// Відтворення: посилання на вузол, який зараз перемалюють
WebElement row = driver.findElement(firstRow);
driver.findElement(By.id("sort-by-date")).click();
row.getText(); // StaleElementReferenceException
// Виправлення: дочекатися зникнення старого вузла і знайти заново
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
WebElement oldRow = driver.findElement(firstRow);
driver.findElement(By.id("sort-by-date")).click();
wait.until(ExpectedConditions.stalenessOf(oldRow));
String newest = wait
.until(ExpectedConditions.visibilityOfElementLocated(firstRow))
.getText();
By firstRow = By.cssSelector("#orders tbody tr");
// Reproduce: a reference to a node that is about to be re-rendered
WebElement row = driver.findElement(firstRow);
driver.findElement(By.id("sort-by-date")).click();
row.getText(); // StaleElementReferenceException
// Fix: wait until the old node is gone, then locate again
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
WebElement oldRow = driver.findElement(firstRow);
driver.findElement(By.id("sort-by-date")).click();
wait.until(ExpectedConditions.stalenessOf(oldRow));
String newest = wait
.until(ExpectedConditions.visibilityOfElementLocated(firstRow))
.getText();
By — це рецепт пошуку, його можна виконувати скільки завгодно разів. stalenessOf(element) повертає true, коли переданий елемент відчепився від DOM, тож очікування саме підтверджує, що перемальовування сталося. visibilityOfElementLocated(By) шукає вузол заново на кожній ітерації опитування і повертає вже свіжий WebElement.By is a search recipe that can be executed any number of times. stalenessOf(element) returns true once the given element is detached from the DOM, so the wait itself proves the re-render happened. visibilityOfElementLocated(By) searches again on every polling iteration and returns a fresh WebElement.Що це. findElement кидає NoSuchElementException, коли в поточному контексті пошуку жоден вузол не відповідає локатору. Ключове слово — поточний контекст: WebDriver шукає лише в активному вікні, в активному фреймі і лише у звичайному DOM, без заходу в shadow root.
What it is. findElement throws NoSuchElementException when no node in the current search context matches the locator. The key words are current context: WebDriver searches only the active window, only the active frame and only the regular DOM, without stepping into a shadow root.
Навіщо. Це найчастіший виняток у звітах, і за ним ховаються зовсім різні причини: друкарська помилка в селекторі, сторінка ще не домалювалась, елемент усередині iframe, клік відкрив нову вкладку, а драйвер лишився в старій, або компонент живе в Shadow DOM. Лікування для кожної причини своє, тому спершу треба діагностувати.
Why it matters. It is the most common exception in reports, and very different causes hide behind it: a typo in the selector, a page that has not finished rendering, an element inside an iframe, a click that opened a new tab while the driver stayed in the old one, or a component living in Shadow DOM. Each cause has its own cure, so diagnosis comes first.
Приклад. Кнопка оплати не знаходиться, хоча в DevTools вона є. Перевіряю по черзі: getCurrentUrl() і кількість вікон — чи я там, де думаю; findElements(...).size() — чи локатор взагалі щось знаходить; дерево в Elements — чи немає над кнопкою тегу iframe або позначки #shadow-root. У цьому випадку форма оплати була у фреймі платіжного провайдера, і рішенням став frameToBeAvailableAndSwitchToIt.
Example. The pay button cannot be found although DevTools shows it. I check in order: getCurrentUrl() and the number of windows, to see whether I am where I think I am; findElements(...).size(), to see whether the locator matches anything at all; and the Elements tree, looking for an iframe tag or a #shadow-root marker above the button. Here the payment form sat inside the payment provider's frame, and frameToBeAvailableAndSwitchToIt solved it.
Підводні камені. Збільшення implicit wait до 30 секунд не допоможе, якщо елемент у фреймі, — тест просто падатиме повільніше. Селектор, скопійований з DevTools через Copy selector, часто прив’язаний до позиції і ламається від першої зміни верстки. І не варто перевіряти відсутність елемента через try/catch навколо findElement: для цього є findElements з порожнім списком.
Pitfalls. Raising the implicit wait to 30 seconds does not help when the element is in a frame; the test just fails more slowly. A selector copied from DevTools with Copy selector is often tied to position and breaks on the first layout change. Also avoid checking for absence with try/catch around findElement; findElements returning an empty list exists for that.
Уточнення. Якщо питають, чим допоможе explicit wait, відповідь така: він закриває лише причину «ще не з’явився». Решту — неправильний контекст чи локатор — жодне очікування не виправить.
Follow-up. If asked how an explicit wait helps, the answer is that it only covers the not-there-yet cause. No wait fixes a wrong context or a wrong locator.
By payButton = By.cssSelector("[data-testid='pay']");
// 1. Чи я в тому вікні й на тій сторінці?
System.out.println(driver.getCurrentUrl() + " / вікон: "
+ driver.getWindowHandles().size());
// 2. Чи знаходить локатор хоч щось (без винятку)?
int matches = driver.findElements(payButton).size();
// 3. Кнопка у фреймі провайдера: спершу перейти в нього
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
wait.until(ExpectedConditions
.frameToBeAvailableAndSwitchToIt(By.cssSelector("iframe#payment")));
wait.until(ExpectedConditions.elementToBeClickable(payButton)).click();
driver.switchTo().defaultContent();
By payButton = By.cssSelector("[data-testid='pay']");
// 1. Am I in the right window and on the right page?
System.out.println(driver.getCurrentUrl() + " / windows: "
+ driver.getWindowHandles().size());
// 2. Does the locator match anything (no exception)?
int matches = driver.findElements(payButton).size();
// 3. The button is in the provider frame: switch into it first
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
wait.until(ExpectedConditions
.frameToBeAvailableAndSwitchToIt(By.cssSelector("iframe#payment")));
wait.until(ExpectedConditions.elementToBeClickable(payButton)).click();
driver.switchTo().defaultContent();
getWindowHandles() повертає набір ідентифікаторів відкритих вікон, його розмір більший за один — привід перевірити, куди перемкнутись. findElements ніколи не кидає виняток і повертає список, тому зручний для діагностики. frameToBeAvailableAndSwitchToIt чекає фрейм і одразу перемикає в нього контекст, а defaultContent() повертає драйвер до головного документа.getWindowHandles() returns the set of open window ids; a size above one is a hint to check where to switch. findElements never throws and returns a list, which makes it handy for diagnosis. frameToBeAvailableAndSwitchToIt waits for the frame and switches the context into it at once, and defaultContent() brings the driver back to the top-level document.Що це. Обидва винятки означають, що елемент знайдено, але взаємодія неможлива. ElementClickInterceptedException — клік потрапив би в інший елемент, який лежить зверху в точці кліку: оверлей завантаження, банер cookies, липкий хедер. ElementNotInteractableException — з самим елементом не можна взаємодіяти: він прихований, має нульовий розмір або до нього не вдається прокрутити.
What it is. Both exceptions mean the element was found but the interaction is impossible. ElementClickInterceptedException means the click would land on another element lying on top at the click point: a loading overlay, a cookie banner, a sticky header. ElementNotInteractableException means the element itself cannot be interacted with: it is hidden, has zero size or cannot be scrolled into view.
Навіщо. Повідомлення винятку підказує, куди дивитися. У тексті intercepted драйвер прямо пише, який елемент отримав би клік, наприклад <div class="spinner-backdrop">. Для not interactable типова причина — локатор знайшов не той із двох однакових елементів, наприклад приховану мобільну версію кнопки.
Why it matters. The exception message tells you where to look. For intercepted clicks the driver names the element that would receive the click, for example <div class="spinner-backdrop">. For not interactable the usual cause is a locator that matched the wrong one of two similar elements, such as a hidden mobile copy of a button.
Приклад. Після збереження форми на пів секунди з’являється напівпрозорий бекдроп, і клік по «Далі» перехоплюється. Правильне виправлення — дочекатися invisibilityOfElementLocated бекдропа, а потім elementToBeClickable кнопки. Якщо заважає банер cookies, тест закриває його так, як зробив би користувач, або вимикає через cookie згоди ще до відкриття сторінки.
Example. After saving a form a translucent backdrop appears for half a second and the click on Next is intercepted. The right fix is to wait for invisibilityOfElementLocated on the backdrop and then for elementToBeClickable on the button. If a cookie banner is in the way, the test closes it the way a user would, or sets the consent cookie before the page is opened.
Підводні камені. elementToBeClickable перевіряє лише, що елемент видимий і enabled; перекриття іншим елементом він не бачить, тож від intercepted сам по собі не рятує. Найгірша «латка» — клік через JavaScript: він обходить перекриття, і тест проходить там, де реальний користувач застряг би. Для липкого хедера допомагає прокрутка елемента в центр, а не догори.
Pitfalls. elementToBeClickable only checks that the element is visible and enabled; it cannot see another element covering it, so on its own it does not prevent intercepted clicks. The worst patch is a JavaScript click: it bypasses the overlay and the test passes where a real user would be stuck. For a sticky header, scrolling the element to the centre of the viewport helps more than scrolling it to the top.
Уточнення. Якщо оверлей не зникає ніколи, це вже баг продукту, і тест має впасти з понятним повідомленням, а не обійти проблему.
Follow-up. If the overlay never goes away, that is a product bug, and the test should fail with a clear message instead of working around it.
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
By backdrop = By.cssSelector(".spinner-backdrop");
By next = By.cssSelector("[data-testid='wizard-next']");
// Бекдроп після збереження має зникнути сам
wait.until(ExpectedConditions.invisibilityOfElementLocated(backdrop));
// Прокрутка, щоб липкий хедер не перекривав кнопку
WebElement button = wait.until(ExpectedConditions.elementToBeClickable(next));
new Actions(driver).scrollToElement(button).perform();
button.click(); // справжній клік, без JavaScript
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
By backdrop = By.cssSelector(".spinner-backdrop");
By next = By.cssSelector("[data-testid='wizard-next']");
// The backdrop after saving must disappear by itself
wait.until(ExpectedConditions.invisibilityOfElementLocated(backdrop));
// Scroll so the sticky header does not cover the button
WebElement button = wait.until(ExpectedConditions.elementToBeClickable(next));
new Actions(driver).scrollToElement(button).perform();
button.click(); // a real click, no JavaScript
invisibilityOfElementLocated повертає true, коли елемента немає в DOM або він невидимий, тож підходить і для оверлея, що видаляється, і для того, що ховається через CSS. elementToBeClickable повертає сам елемент, коли той видимий і enabled. Actions.scrollToElement з’явився в Selenium 4.2 і прокручує через Actions API, а perform() відправляє накопичені дії драйверу.invisibilityOfElementLocated returns true when the element is absent from the DOM or invisible, so it fits both an overlay that is removed and one hidden with CSS. elementToBeClickable returns the element itself once it is visible and enabled. Actions.scrollToElement arrived in Selenium 4.2 and scrolls through the Actions API, and perform() sends the queued actions to the driver.Що це. Iframe — окремий документ усередині сторінки зі своїм DOM. WebDriver завжди шукає елементи лише в поточному контексті перегляду, тому поле оплати у фреймі платіжного провайдера для основного документа просто не існує. Перейти всередину можна трьома способами: switchTo().frame(0) за індексом, switchTo().frame("payment") за атрибутом name чи id і switchTo().frame(element) за знайденим WebElement.
What it is. An iframe is a separate document inside the page with its own DOM. WebDriver only searches the current browsing context, so a card field inside a payment provider frame simply does not exist for the main document. You step inside in one of three ways: switchTo().frame(0) by index, switchTo().frame("payment") by the name or id attribute, or switchTo().frame(element) with a located WebElement.
Навіщо. Фрейми трапляються в платіжних формах, капчах, WYSIWYG-редакторах і вбудованих віджетах. Без перемикання контексту тест отримує NoSuchElementException, хоча елемент чудово видно в DevTools, і новачок годинами переписує локатор замість однієї команди.
Why it matters. Frames show up in payment forms, captchas, rich text editors and embedded widgets. Without a context switch the test gets NoSuchElementException even though the element is plainly visible in DevTools, and a newcomer spends hours rewriting the locator when one command was missing.
Приклад. На сторінці checkout фрейм з картою вантажиться асинхронно, тому я чекаю його через ExpectedConditions.frameToBeAvailableAndSwitchToIt(By.cssSelector("iframe[title='Card']")): умова дочекається фрейму і одразу переключить у нього. Далі вводжу номер карти, а для кнопки «Оплатити» в основному документі повертаюсь через defaultContent().
Example. On the checkout page the card frame loads asynchronously, so I wait for it with ExpectedConditions.frameToBeAvailableAndSwitchToIt(By.cssSelector("iframe[title='Card']")): the condition waits for the frame and switches into it in one go. Then I type the card number, and for the Pay button that lives in the main document I go back with defaultContent().
Підводні камені. defaultContent() повертає на самий верх, до головного документа, а parentFrame() — лише на один рівень вище. У вкладених фреймах це важливо: з внутрішнього фрейму до сусіднього в межах того ж батька треба піти в parentFrame(), а не на верх. Індекс крихкий: рекламний банер з новим iframe зсуне нумерацію. І забутий вихід з фрейму ламає всі наступні кроки тесту.
Pitfalls. defaultContent() jumps all the way up to the top-level document, while parentFrame() moves exactly one level up. With nested frames that matters: to reach a sibling frame under the same parent you go to parentFrame(), not to the top. Switching by index is brittle, since an ad banner with a new iframe shifts the numbering. And forgetting to leave a frame breaks every later step of the test.
Уточнення. Питають, чи треба перемикатись у Shadow DOM так само. Ні: shadow root — частина того самого документа, його відкривають через getShadowRoot(), а не через switchTo().
Follow-up. People ask whether Shadow DOM needs the same switch. It does not: a shadow root belongs to the same document and is opened with getShadowRoot(), not with switchTo().
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
// чекаємо фрейм і одразу переходимо в нього
wait.until(ExpectedConditions.frameToBeAvailableAndSwitchToIt(
By.cssSelector("iframe[title='Card']")));
driver.findElement(By.name("cardnumber")).sendKeys("4242424242424242");
// вкладений фрейм з CVC всередині фрейму карти
driver.switchTo().frame("cvc-frame");
driver.findElement(By.name("cvc")).sendKeys("123");
driver.switchTo().parentFrame(); // назад у фрейм карти
driver.switchTo().defaultContent(); // на верхній рівень сторінки
driver.findElement(By.id("pay")).click();
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
// wait for the frame and switch into it right away
wait.until(ExpectedConditions.frameToBeAvailableAndSwitchToIt(
By.cssSelector("iframe[title='Card']")));
driver.findElement(By.name("cardnumber")).sendKeys("4242424242424242");
// nested CVC frame inside the card frame
driver.switchTo().frame("cvc-frame");
driver.findElement(By.name("cvc")).sendKeys("123");
driver.switchTo().parentFrame(); // back to the card frame
driver.switchTo().defaultContent(); // up to the top-level page
driver.findElement(By.id("pay")).click();
frameToBeAvailableAndSwitchToIt приймає By, індекс, ім’я чи WebElement і сама виконує перемикання, коли фрейм з’явився. switchTo().frame("cvc-frame") шукає фрейм за name або id лише в поточному контексті. parentFrame() піднімає на рівень вище, defaultContent() — до головного документа.frameToBeAvailableAndSwitchToIt accepts a By, an index, a name or a WebElement and performs the switch itself once the frame is there. switchTo().frame("cvc-frame") looks for a frame by name or id only within the current context. parentFrame() climbs one level, defaultContent() returns to the main document.Що це. Кожне вікно чи вкладка в сесії має window handle — унікальний рядок-ідентифікатор. getWindowHandle() повертає handle поточного вікна, getWindowHandles() — множину всіх відкритих. Команди завжди йдуть у поточне вікно, а змінити його можна лише явно через switchTo().window(handle): драйвер не переходить у нову вкладку сам, навіть якщо браузер її показав.
What it is. Every window or tab in a session has a window handle, a unique string id. getWindowHandle() returns the handle of the current window and getWindowHandles() returns the set of all open ones. Commands always go to the current window, and you change it only explicitly with switchTo().window(handle): the driver never follows a new tab by itself, even when the browser shows it.
Навіщо. Посилання з target="_blank", OAuth-логін через Google, перегляд PDF чи сторінка підтримки часто відкриваються в новій вкладці. Тест має знайти її, перевірити вміст і повернутись назад, не загубивши вихідне вікно.
Why it matters. Links with target="_blank", an OAuth login through Google, a PDF preview or a help page often open in a new tab. The test has to find that tab, check its content and come back without losing the original window.
Приклад. Запам’ятовую handle оригіналу, клікаю «Умови використання», чекаю numberOfWindowsToBe(2), перебираю handles і перемикаюсь на той, що відрізняється від оригіналу. Перевіряю заголовок, закриваю вкладку через close() і повертаюсь командою switchTo().window(original).
Example. I store the original handle, click Terms of Use, wait for numberOfWindowsToBe(2), loop over the handles and switch to the one that differs from the original. I check the title, close the tab with close() and return with switchTo().window(original).
Підводні камені. getWindowHandles() повертає Set, і порядок у ньому не гарантований, тож брати «другий елемент» ненадійно. Після close() драйвер лишається «в порожнечі», і наступна команда без перемикання кине NoSuchWindowException. Нова вкладка з’являється не миттєво, тому без очікування кількості вікон тест стає flaky.
Pitfalls. getWindowHandles() returns a Set with no guaranteed order, so taking the second element is unreliable. After close() the driver is left pointing at nothing, and the next command without a switch throws NoSuchWindowException. A new tab does not appear instantly, so without waiting for the window count the test turns flaky.
Уточнення. У Selenium 4 з’явився switchTo().newWindow(WindowType.TAB) або WindowType.WINDOW: він відкриває порожню вкладку і одразу перемикає в неї. Це зручно, щоб у тій самій сесії з тими ж cookies відкрити адмінку паралельно з клієнтською частиною.
Follow-up. Selenium 4 added switchTo().newWindow(WindowType.TAB) or WindowType.WINDOW: it opens a blank tab and switches into it immediately. That is handy for opening the admin panel next to the customer site in the same session with the same cookies.
String original = driver.getWindowHandle();
driver.findElement(By.linkText("Terms of Use")).click();
new WebDriverWait(driver, Duration.ofSeconds(5))
.until(ExpectedConditions.numberOfWindowsToBe(2));
for (String handle : driver.getWindowHandles()) {
if (!handle.equals(original)) {
driver.switchTo().window(handle); // перемикаємось явно
}
}
assertThat(driver.getTitle()).contains("Terms");
driver.close(); // закриває лише поточну вкладку
driver.switchTo().window(original); // без цього буде NoSuchWindowException
// Selenium 4: відкрити порожню вкладку і одразу перейти в неї
driver.switchTo().newWindow(WindowType.TAB);
driver.get("https://admin.example.com");
String original = driver.getWindowHandle();
driver.findElement(By.linkText("Terms of Use")).click();
new WebDriverWait(driver, Duration.ofSeconds(5))
.until(ExpectedConditions.numberOfWindowsToBe(2));
for (String handle : driver.getWindowHandles()) {
if (!handle.equals(original)) {
driver.switchTo().window(handle); // switch explicitly
}
}
assertThat(driver.getTitle()).contains("Terms");
driver.close(); // closes only the current tab
driver.switchTo().window(original); // otherwise NoSuchWindowException
// Selenium 4: open a blank tab and switch into it at once
driver.switchTo().newWindow(WindowType.TAB);
driver.get("https://admin.example.com");
getWindowHandle() повертає String, getWindowHandles() — Set<String>. numberOfWindowsToBe(2) — готова умова з ExpectedConditions. Цикл шукає handle, не рівний оригіналу. assertThat тут з AssertJ. WindowType — перелік із двома значеннями: TAB і WINDOW.getWindowHandle() returns a String, getWindowHandles() a Set<String>. numberOfWindowsToBe(2) is a ready-made condition from ExpectedConditions. The loop looks for the handle that is not the original. assertThat comes from AssertJ. WindowType is an enum with two values, TAB and WINDOW.Що це. alert(), confirm() і prompt() — нативні діалоги браузера, а не частина DOM, тому знайти їх через findElement неможливо. З ними працюють через driver.switchTo().alert(), який повертає об’єкт Alert з методами getText(), accept(), dismiss() і sendKeys() для введення тексту в prompt.
What it is. alert(), confirm() and prompt() are native browser dialogs, not part of the DOM, so findElement can never reach them. You work with them through driver.switchTo().alert(), which returns an Alert object with getText(), accept(), dismiss() and sendKeys() for typing into a prompt.
Навіщо. Підтвердження видалення, попередження про незбережені зміни або старі адмінки часто спираються саме на нативні діалоги. Поки діалог відкритий, браузер блокує сторінку, і будь-яка інша команда падає.
Why it matters. Delete confirmations, unsaved-changes warnings and older admin panels often rely on native dialogs. While one is open the browser blocks the page, and every other command fails.
Приклад. Тест видаляє замовлення: клік по «Видалити» викликає confirm('Delete order 42?'). Я чекаю діалог через ExpectedConditions.alertIsPresent(), перевіряю текст, щоб переконатися, що видаляється саме те замовлення, і натискаю accept(). Для негативного сценарію викликаю dismiss() і перевіряю, що рядок лишився в таблиці.
Example. A test deletes an order: clicking Delete calls confirm('Delete order 42?'). I wait for the dialog with ExpectedConditions.alertIsPresent(), check the text to be sure the right order is being removed, and call accept(). For the negative case I call dismiss() and verify the row is still in the table.
Підводні камені. Модальне вікно на HTML і CSS — не алерт: воно живе в DOM, і switchTo().alert() кине NoAlertPresentException. Якщо нативний діалог несподівано з’явився і його ніхто не обробив, наступна команда отримає UnhandledAlertException. Поведінку в такій ситуації задає капабіліті unhandledPromptBehavior; за замовчуванням за W3C діалог закривається з помилкою, але її можна змінити, наприклад, на автоматичне прийняття.
Pitfalls. A modal built with HTML and CSS is not an alert: it lives in the DOM, and switchTo().alert() throws NoAlertPresentException. If a native dialog pops up unexpectedly and nobody handles it, the next command gets UnhandledAlertException. The unhandledPromptBehavior capability decides what happens then; the W3C default dismisses the dialog and reports an error, but you can change it, for example to accept automatically.
Уточнення. Питають, чи варто всюди вмикати автоприйняття. Я б не став: несподіваний алерт часто і є багом, а тихе прийняття сховає його від тесту.
Follow-up. You may be asked whether to turn on auto-accept everywhere. I would not: an unexpected alert is often the bug itself, and silently accepting it hides that from the test.
driver.findElement(By.cssSelector("[data-test='delete-order-42']")).click();
Alert alert = new WebDriverWait(driver, Duration.ofSeconds(5))
.until(ExpectedConditions.alertIsPresent());
assertThat(alert.getText()).isEqualTo("Delete order 42?");
alert.accept(); // «OK»; dismiss() натиснув би «Скасувати»
// prompt(): текст вводиться в сам діалог
// alert.sendKeys("reason: duplicate");
// поведінка для алертів, які тест не чекав
ChromeOptions options = new ChromeOptions();
options.setUnhandledPromptBehaviour(UnexpectedAlertBehaviour.DISMISS_AND_NOTIFY);
driver.findElement(By.cssSelector("[data-test='delete-order-42']")).click();
Alert alert = new WebDriverWait(driver, Duration.ofSeconds(5))
.until(ExpectedConditions.alertIsPresent());
assertThat(alert.getText()).isEqualTo("Delete order 42?");
alert.accept(); // OK; dismiss() would press Cancel
// prompt(): the text goes into the dialog itself
// alert.sendKeys("reason: duplicate");
// behaviour for alerts the test did not expect
ChromeOptions options = new ChromeOptions();
options.setUnhandledPromptBehaviour(UnexpectedAlertBehaviour.DISMISS_AND_NOTIFY);
alertIsPresent() чекає діалог і повертає вже готовий Alert, тож окремий switchTo().alert() не потрібен. accept() відповідає кнопці OK, dismiss() — Cancel. setUnhandledPromptBehaviour (британське написання в назві методу) записує капабіліті unhandledPromptBehavior; UnexpectedAlertBehaviour має значення ACCEPT, DISMISS, ACCEPT_AND_NOTIFY, DISMISS_AND_NOTIFY та IGNORE.alertIsPresent() waits for the dialog and returns a ready Alert, so a separate switchTo().alert() is not needed. accept() maps to OK and dismiss() to Cancel. setUnhandledPromptBehaviour (British spelling in the method name) writes the unhandledPromptBehavior capability; UnexpectedAlertBehaviour has the values ACCEPT, DISMISS, ACCEPT_AND_NOTIFY, DISMISS_AND_NOTIFY and IGNORE.Що це. Upload у Selenium робиться через sendKeys з абсолютним шляхом до файлу, надісланим прямо в елемент <input type="file">. Драйвер сам підставляє файл у поле, і браузер поводиться так, ніби користувач обрав його в діалозі.
What it is. An upload in Selenium is done with sendKeys, sending the absolute file path straight into the <input type="file"> element. The driver attaches the file itself, and the browser behaves as if the user had picked it in the dialog.
Навіщо. Клік по кнопці відкриває системне вікно вибору файлу ОС, а воно вже поза межами браузера: WebDriver не бачить його і не може ним керувати. Тому будь-які спроби натиснути «Обрати файл» і потім «ввести шлях» закінчуються зависанням тесту.
Why it matters. Clicking the button opens the operating system file picker, which lives outside the browser: WebDriver cannot see or drive it. Any attempt to click Choose file and then type a path ends with a hanging test.
Приклад. Тест аватара бере файл з ресурсів проєкту, перетворює шлях на абсолютний через Path.toAbsolutePath() і шле його в поле. Для кількох файлів поле має мати атрибут multiple, а шляхи з’єднуються символом нового рядка. Після цього тест чекає прев’ю або рядок у списку завантажених.
Example. An avatar test takes a file from the project resources, turns the path into an absolute one with Path.toAbsolutePath() and sends it into the field. For several files the input needs the multiple attribute, and the paths are joined with a newline character. Then the test waits for the preview or a row in the uploaded list.
Підводні камені. Сучасні дизайни ховають справжній input і показують стилізовану кнопку. Якщо input має display: none, sendKeys може кинути ElementNotInteractableException; частіше input просто прозорий, і тоді все працює. Відносний шлях теж ламається, бо браузер не знає робочої теки тесту. На Selenium Grid файл лежить на машині тесту, а браузер — на ноді, тому потрібен LocalFileDetector: він передає файл на ноду перед завантаженням.
Pitfalls. Modern designs hide the real input behind a styled button. If the input has display: none, sendKeys may throw ElementNotInteractableException; more often the input is merely transparent and everything works. A relative path also fails, because the browser has no idea of the test working directory. On Selenium Grid the file sits on the test machine while the browser runs on a node, so you need LocalFileDetector, which ships the file to the node before the upload.
Уточнення. Буває, що питають про drag-and-drop зони без input. Там зазвичай все одно є прихований input, а якщо його немає, я тестую завантаження через API, а в UI лише перевіряю відображення.
Follow-up. You might get asked about drag-and-drop zones with no input. There is usually still a hidden input; if there really is none, I cover the upload through the API and in the UI only check how the result is displayed.
Path avatar = Path.of("src/test/resources/avatar.png").toAbsolutePath();
// у RemoteWebDriver файл спершу копіюється на ноду Grid
if (driver instanceof RemoteWebDriver remote) {
remote.setFileDetector(new LocalFileDetector());
}
WebElement input = driver.findElement(By.cssSelector("input[type='file']"));
input.sendKeys(avatar.toString()); // жодного кліку по кнопці
// кілька файлів: потрібен атрибут multiple, шляхи через \n
String both = avatar + "\n" + Path.of("src/test/resources/cv.pdf").toAbsolutePath();
driver.findElement(By.id("attachments")).sendKeys(both);
Path avatar = Path.of("src/test/resources/avatar.png").toAbsolutePath();
// with RemoteWebDriver the file is copied to the Grid node first
if (driver instanceof RemoteWebDriver remote) {
remote.setFileDetector(new LocalFileDetector());
}
WebElement input = driver.findElement(By.cssSelector("input[type='file']"));
input.sendKeys(avatar.toString()); // no click on the button at all
// several files: needs the multiple attribute, paths joined by \n
String both = avatar + "\n" + Path.of("src/test/resources/cv.pdf").toAbsolutePath();
driver.findElement(By.id("attachments")).sendKeys(both);
Path.of(...).toAbsolutePath() з java.nio.file робить шлях абсолютним. instanceof RemoteWebDriver remote — pattern matching з Java 16+, який одразу оголошує змінну. setFileDetector(new LocalFileDetector()) каже клієнту перевіряти, чи рядок у sendKeys — локальний файл, і якщо так, завантажувати його на ноду. "\n" розділяє кілька шляхів.Path.of(...).toAbsolutePath() from java.nio.file makes the path absolute. instanceof RemoteWebDriver remote is Java 16+ pattern matching that declares the variable on the spot. setFileDetector(new LocalFileDetector()) tells the client to check whether a sendKeys string is a local file and, if so, upload it to the node. "\n" separates several paths.Що це. Selenium не має команди «перевір завантажений файл»: він бачить сторінку, а не файлову систему. Тому тест налаштовує браузер зберігати файли в відому теку без діалогу, клікає «Експорт», а потім уже засобами Java чекає появу файлу і перевіряє його ім’я, розмір і вміст.
What it is. Selenium has no command to check a downloaded file: it sees the page, not the file system. So the test configures the browser to save files into a known folder without a dialog, clicks Export, and then uses plain Java to wait for the file and check its name, size and content.
Навіщо. Звіти CSV, рахунки PDF і вивантаження Excel — часто бізнес-критичні функції. Перевірка лише того, що кнопка натиснулась, нічого не гарантує: файл може бути порожнім, з неправильним кодуванням чи назвою.
Why it matters. CSV reports, PDF invoices and Excel exports are often business-critical. Checking only that the button was clicked guarantees nothing: the file can be empty, in the wrong encoding or badly named.
Приклад. Для Chrome задаю prefs download.default_directory з унікальною текою на кожен тест і download.prompt_for_download=false. Після кліку чекаю через FluentWait, поки в теці з’явиться report.csv і зникне тимчасовий файл .crdownload. Далі читаю рядки і перевіряю заголовок та кількість записів.
Example. For Chrome I set the prefs download.default_directory to a unique folder per test and download.prompt_for_download=false. After the click I wait with a FluentWait until report.csv is in the folder and the temporary .crdownload file is gone. Then I read the lines and check the header and the number of records.
Підводні камені. Спільна тека для паралельних тестів дає змішані файли з чужих прогонів. Перевірка одразу після кліку ловить недописаний файл. На Grid файл з’являється на ноді, а не на машині тесту, тож локальна перевірка теки марна; Selenium 4 має керовані завантаження через options.setEnableDownloads(true), коли нода зберігає файл і віддає його клієнту на запит.
Pitfalls. A shared folder for parallel tests mixes files from other runs. Checking right after the click catches a half-written file. On Grid the file lands on the node, not on the test machine, so inspecting a local folder is pointless; Selenium 4 offers managed downloads through options.setEnableDownloads(true), where the node keeps the file and hands it to the client on request.
Уточнення. Альтернатива — не завантажувати через браузер взагалі: взяти href посилання, передати cookies сесії в HTTP-клієнт і скачати файл напряму. Це швидше і стабільніше, але не перевіряє саму поведінку браузера, наприклад заголовок Content-Disposition в дії.
Follow-up. An alternative is to skip the browser download entirely: read the link href, pass the session cookies to an HTTP client and fetch the file directly. It is faster and steadier, but it does not exercise the browser behaviour itself, such as the Content-Disposition header in action.
Path dir = Files.createTempDirectory("downloads");
Map<String, Object> prefs = new HashMap<>();
prefs.put("download.default_directory", dir.toString());
prefs.put("download.prompt_for_download", false);
ChromeOptions options = new ChromeOptions();
options.setExperimentalOption("prefs", prefs);
WebDriver driver = new ChromeDriver(options);
driver.get("https://shop.example.com/reports");
driver.findElement(By.id("export-csv")).click();
Path report = dir.resolve("report.csv");
new FluentWait<>(dir)
.withTimeout(Duration.ofSeconds(30))
.pollingEvery(Duration.ofMillis(500))
.until(d -> Files.exists(report)
&& !Files.exists(d.resolve("report.csv.crdownload"))); // файл дописано
List<String> lines = Files.readAllLines(report);
assertThat(lines.get(0)).isEqualTo("id,total,status"); // заголовок CSV
assertThat(lines).hasSize(11); // заголовок + 10 рядків
Path dir = Files.createTempDirectory("downloads");
Map<String, Object> prefs = new HashMap<>();
prefs.put("download.default_directory", dir.toString());
prefs.put("download.prompt_for_download", false);
ChromeOptions options = new ChromeOptions();
options.setExperimentalOption("prefs", prefs);
WebDriver driver = new ChromeDriver(options);
driver.get("https://shop.example.com/reports");
driver.findElement(By.id("export-csv")).click();
Path report = dir.resolve("report.csv");
new FluentWait<>(dir)
.withTimeout(Duration.ofSeconds(30))
.pollingEvery(Duration.ofMillis(500))
.until(d -> Files.exists(report)
&& !Files.exists(d.resolve("report.csv.crdownload"))); // fully written
List<String> lines = Files.readAllLines(report);
assertThat(lines.get(0)).isEqualTo("id,total,status"); // CSV header
assertThat(lines).hasSize(11); // header + 10 rows
setExperimentalOption("prefs", prefs) передає Chrome налаштування профілю. Files.createTempDirectory дає унікальну теку, тож паралельні тести не заважають одне одному. FluentWait тут параметризований не драйвером, а Path: лямбда отримує теку і повертає true, коли файл готовий. .crdownload — тимчасове розширення, яке Chrome знімає після завершення.setExperimentalOption("prefs", prefs) passes profile settings to Chrome. Files.createTempDirectory gives a unique folder, so parallel tests do not step on each other. The FluentWait here is typed with a Path rather than the driver: the lambda gets the folder and returns true once the file is ready. .crdownload is the temporary extension Chrome removes when it finishes.Що це. Будь-який драйвер Selenium реалізує інтерфейс TakesScreenshot: getScreenshotAs(OutputType.BYTES) або OutputType.FILE повертає знімок видимої області вікна. У Selenium 4 WebElement теж уміє знімати себе, що зручно для перевірки конкретного віджета.
What it is. Every Selenium driver implements the TakesScreenshot interface: getScreenshotAs(OutputType.BYTES) or OutputType.FILE returns an image of the visible part of the window. In Selenium 4 a WebElement can capture itself too, which is handy for checking one specific widget.
Навіщо. Стектрейс «елемент не клікабельний» мало що каже, а скріншот одразу показує банер cookies, спінер чи сторінку помилки 500. Разом із HTML сторінки і поточним URL це три артефакти, з якими падіння в CI можна розібрати без локального відтворення.
Why it matters. A stack trace saying the element is not clickable tells you little, while a screenshot instantly shows a cookie banner, a spinner or a 500 error page. Together with the page HTML and the current URL, those are three artefacts that let you analyse a CI failure without reproducing it locally.
Приклад. У JUnit 5 я пишу розширення, що реалізує AfterTestExecutionCallback. Воно викликається одразу після тіла тесту, ще до методів @AfterEach, перевіряє context.getExecutionException() і, якщо там є помилка, зберігає PNG і getPageSource() у теку артефактів з назвою тесту.
Example. In JUnit 5 I write an extension implementing AfterTestExecutionCallback. It runs right after the test body and before any @AfterEach method, looks at context.getExecutionException() and, if an error is present, saves a PNG and getPageSource() into the artefacts folder under the test name.
Підводні камені. Класична помилка — знімати скріншот у TestWatcher.testFailed: цей колбек спрацьовує вже після @AfterEach, де драйвер закрито через quit(), і замість знімка маємо ще одну помилку. Друге: знімок охоплює лише видиму частину, а не всю сторінку, тож елемент нижче згину може в кадр не потрапити. Третє: у headless без заданого розміру вікна знімок буде маленьким і незрозумілим.
Pitfalls. The classic mistake is taking the screenshot in TestWatcher.testFailed: that callback fires after @AfterEach, where the driver has already been shut with quit(), so instead of an image you get one more exception. Second, the capture covers only the visible area, not the whole page, so an element below the fold may be out of frame. Third, in headless mode without a set window size the image comes out small and hard to read.
Уточнення. Питають про повносторінкові знімки. У Firefox є FirefoxDriver.getFullPageScreenshotAs, у Chromium зазвичай йдуть через CDP; я б не робив цього на кожне падіння, бо файли великі.
Follow-up. Full-page captures come up too. Firefox has FirefoxDriver.getFullPageScreenshotAs, while for Chromium people usually go through CDP; I would not do it on every failure, since the files are large.
public interface HasDriver { WebDriver driver(); }
public class ScreenshotOnFailure implements AfterTestExecutionCallback {
@Override
public void afterTestExecution(ExtensionContext context) throws Exception {
if (context.getExecutionException().isEmpty()) return; // тест пройшов
WebDriver driver = ((HasDriver) context.getRequiredTestInstance()).driver();
Path dir = Files.createDirectories(Path.of("target/artifacts"));
String name = context.getRequiredTestMethod().getName();
byte[] png = ((TakesScreenshot) driver).getScreenshotAs(OutputType.BYTES);
Files.write(dir.resolve(name + ".png"), png);
Files.writeString(dir.resolve(name + ".html"), driver.getPageSource());
}
}
// знімок одного елемента (Selenium 4)
File chart = driver.findElement(By.id("sales-chart")).getScreenshotAs(OutputType.FILE);
public interface HasDriver { WebDriver driver(); }
public class ScreenshotOnFailure implements AfterTestExecutionCallback {
@Override
public void afterTestExecution(ExtensionContext context) throws Exception {
if (context.getExecutionException().isEmpty()) return; // the test passed
WebDriver driver = ((HasDriver) context.getRequiredTestInstance()).driver();
Path dir = Files.createDirectories(Path.of("target/artifacts"));
String name = context.getRequiredTestMethod().getName();
byte[] png = ((TakesScreenshot) driver).getScreenshotAs(OutputType.BYTES);
Files.write(dir.resolve(name + ".png"), png);
Files.writeString(dir.resolve(name + ".html"), driver.getPageSource());
}
}
// a capture of one element (Selenium 4)
File chart = driver.findElement(By.id("sales-chart")).getScreenshotAs(OutputType.FILE);
getExecutionException() повертає Optional<Throwable>: порожній означає успіх. getRequiredTestInstance() дає об’єкт тестового класу, а інтерфейс HasDriver дозволяє дістати з нього драйвер без рефлексії. Каст до TakesScreenshot потрібен, бо WebDriver сам цього методу не має. Розширення підключають анотацією @ExtendWith(ScreenshotOnFailure.class).getExecutionException() returns an Optional<Throwable>: empty means success. getRequiredTestInstance() gives the test class object, and the HasDriver interface lets you pull the driver out of it without reflection. The cast to TakesScreenshot is needed because WebDriver itself lacks that method. You register the extension with @ExtendWith(ScreenshotOnFailure.class).Що це. Cookies поточного домену доступні через driver.manage(): addCookie, getCookies, getCookieNamed, deleteCookieNamed і deleteAllCookies. Об’єкт Cookie має ім’я, значення, домен, шлях, термін дії та прапорці secure і HttpOnly.
What it is. Cookies of the current domain are available through driver.manage(): addCookie, getCookies, getCookieNamed, deleteCookieNamed and deleteAllCookies. A Cookie object has a name, value, domain, path, expiry and the secure and HttpOnly flags.
Навіщо. Найцінніше застосування — швидкий вхід. Замість того щоб у кожному тесті проходити форму логіну, отримуємо токен сесії через API і кладемо його в браузер як cookie. Тест стартує одразу з потрібної сторінки, а форму логіну перевіряє один окремий тест. Також cookies допомагають перевірити, що після логауту сесійна cookie справді зникла.
Why it matters. The most valuable use is a fast login. Instead of filling in the login form in every test, you get a session token from the API and put it into the browser as a cookie. The test starts right on the page it needs, and one dedicated test covers the login form. Cookies also let you check that the session cookie really disappears after logout.
Приклад. Тест викликає POST /api/login, бере значення SESSION, відкриває будь-яку легку сторінку домену, наприклад /robots.txt, додає cookie, робить refresh() і переходить у кабінет уже залогіненим.
Example. The test calls POST /api/login, takes the SESSION value, opens any lightweight page of the domain such as /robots.txt, adds the cookie, calls refresh() and moves on to the account area already signed in.
Підводні камені. Додати cookie можна лише для домену, який зараз відкритий: виклик на about:blank або іншому сайті дає InvalidCookieDomainException. Прапорець secure вимагає HTTPS. deleteAllCookies() чистить лише поточний домен, а не весь профіль. Якщо застосунок тримає сесію не в cookie, а в localStorage з JWT, такий трюк не спрацює, і токен треба класти через JavaScript.
Pitfalls. You can only add a cookie for the domain that is currently open: doing it on about:blank or another site raises InvalidCookieDomainException. The secure flag requires HTTPS. deleteAllCookies() clears only the current domain, not the whole profile. If the app keeps the session in localStorage as a JWT instead of a cookie, this trick fails and the token has to be set through JavaScript.
Уточнення. Чи бачить Selenium HttpOnly-cookies? Так: заборона HttpOnly стосується document.cookie у JavaScript сторінки, а WebDriver читає і пише їх через протокол.
Follow-up. Can Selenium see HttpOnly cookies? Yes: the HttpOnly restriction applies to document.cookie in page JavaScript, while WebDriver reads and writes them through the protocol.
String token = authApi.login("buyer@example.com", "s3cret"); // сесія через API
driver.get("https://shop.example.com/robots.txt"); // спершу відкрити домен
Cookie session = new Cookie.Builder("SESSION", token)
.domain("shop.example.com")
.path("/")
.isSecure(true)
.isHttpOnly(true)
.build();
driver.manage().addCookie(session);
driver.navigate().refresh();
driver.get("https://shop.example.com/account");
assertThat(driver.findElement(By.id("user-name")).getText()).isEqualTo("Buyer");
// після логауту cookie має зникнути
driver.findElement(By.id("logout")).click();
assertThat(driver.manage().getCookieNamed("SESSION")).isNull();
String token = authApi.login("buyer@example.com", "s3cret"); // session via the API
driver.get("https://shop.example.com/robots.txt"); // open the domain first
Cookie session = new Cookie.Builder("SESSION", token)
.domain("shop.example.com")
.path("/")
.isSecure(true)
.isHttpOnly(true)
.build();
driver.manage().addCookie(session);
driver.navigate().refresh();
driver.get("https://shop.example.com/account");
assertThat(driver.findElement(By.id("user-name")).getText()).isEqualTo("Buyer");
// after logout the cookie must be gone
driver.findElement(By.id("logout")).click();
assertThat(driver.manage().getCookieNamed("SESSION")).isNull();
authApi.login — умовний API-клієнт проєкту, який повертає значення сесії. Cookie.Builder дозволяє задати домен, шлях і прапорці; простіший варіант — new Cookie("SESSION", token). navigate().refresh() перезавантажує сторінку, щоб сервер уже побачив cookie. getCookieNamed повертає null, якщо cookie немає.authApi.login stands for the project API client that returns the session value. Cookie.Builder lets you set domain, path and flags; the simpler form is new Cookie("SESSION", token). navigate().refresh() reloads the page so the server sees the cookie. getCookieNamed returns null when there is no such cookie.Що це. Page Object — клас, який описує одну сторінку або великий компонент: зберігає її локатори і надає методи-дії мовою предметної області, наприклад loginAs(email, password). Тест викликає ці методи і робить перевірки, а про CSS-селектори й очікування нічого не знає.
What it is. A Page Object is a class that describes one page or a large component: it holds that page's locators and offers action methods in domain language, such as loginAs(email, password). The test calls those methods and makes the assertions, knowing nothing about CSS selectors or waits.
Навіщо. Коли розробники перейменують id поля пароля, виправити доведеться один рядок у LoginPage, а не сорок тестів. Тести читаються як сценарій, а не як набір кліків, і їх легше рев’ювити бізнес-аналітику чи новачку в команді.
Why it matters. When developers rename the id of the password field, you fix one line in LoginPage instead of forty tests. Tests read like a scenario rather than a list of clicks, and a business analyst or a newcomer to the team can review them more easily.
Приклад. LoginPage тримає By-поля, отримує WebDriver у конструкторі і має два методи входу: успішний повертає DashboardPage, а невдалий — ту саму LoginPage, щоб тест міг прочитати текст помилки. Такий стиль називають fluent: кожен метод повертає сторінку, на якій користувач опиниться після дії, і ланцюжок викликів сам документує навігацію.
Example. LoginPage keeps By fields, receives a WebDriver in its constructor and has two login methods: the successful one returns a DashboardPage, while the failing one returns the same LoginPage, so the test can read the error text. This fluent style means each method returns the page the user lands on after the action, and the call chain documents navigation by itself.
Підводні камені. Асерти всередині page object — поширена помилка: сторінка перестає бути перевикористовуваною, бо жорстко вирішує, що вважати правильним. Друга помилка — «божественний» клас на 800 рядків для всього застосунку; краще ділити на сторінки й компоненти на кшталт HeaderComponent. Третя — зберігати знайдені WebElement у полях, що веде до stale-винятків.
Pitfalls. Assertions inside a page object are a common mistake: the page stops being reusable because it decides on its own what counts as correct. The second mistake is a god class of 800 lines for the whole application; splitting into pages and components like HeaderComponent works better. The third is storing found WebElement objects in fields, which leads to stale exceptions.
Уточнення. Часто питають, де мають жити очікування. Розумний варіант — усередині методів page object, бо саме сторінка знає, який стан означає «готова до дії».
Follow-up. A frequent question is where waits should live. A sensible answer is inside page object methods, because the page is what knows which state means ready for the next action.
public class LoginPage {
private final WebDriver driver;
private final WebDriverWait wait;
private final By email = By.id("email");
private final By password = By.id("password");
private final By submit = By.cssSelector("[data-testid='login-submit']");
private final By error = By.cssSelector("[role='alert']");
public LoginPage(WebDriver driver) {
this.driver = driver;
this.wait = new WebDriverWait(driver, Duration.ofSeconds(10));
}
public LoginPage open(String baseUrl) {
driver.get(baseUrl + "/login");
wait.until(ExpectedConditions.visibilityOfElementLocated(email));
return this;
}
public DashboardPage loginAs(String user, String pass) {
fillAndSubmit(user, pass);
return new DashboardPage(driver); // наступна сторінка
}
public LoginPage loginExpectingError(String user, String pass) {
fillAndSubmit(user, pass);
wait.until(ExpectedConditions.visibilityOfElementLocated(error));
return this; // лишаємось на сторінці входу
}
public String errorText() {
return driver.findElement(error).getText();
}
private void fillAndSubmit(String user, String pass) {
driver.findElement(email).sendKeys(user);
driver.findElement(password).sendKeys(pass);
driver.findElement(submit).click();
}
}
// Тест: перевірки живуть тут, а не в сторінці
@Test
void wrongPasswordShowsError() {
String message = new LoginPage(driver)
.open(BASE_URL)
.loginExpectingError("anna@example.com", "wrong-pass")
.errorText();
assertEquals("Invalid email or password", message);
}
public class LoginPage {
private final WebDriver driver;
private final WebDriverWait wait;
private final By email = By.id("email");
private final By password = By.id("password");
private final By submit = By.cssSelector("[data-testid='login-submit']");
private final By error = By.cssSelector("[role='alert']");
public LoginPage(WebDriver driver) {
this.driver = driver;
this.wait = new WebDriverWait(driver, Duration.ofSeconds(10));
}
public LoginPage open(String baseUrl) {
driver.get(baseUrl + "/login");
wait.until(ExpectedConditions.visibilityOfElementLocated(email));
return this;
}
public DashboardPage loginAs(String user, String pass) {
fillAndSubmit(user, pass);
return new DashboardPage(driver); // the next page
}
public LoginPage loginExpectingError(String user, String pass) {
fillAndSubmit(user, pass);
wait.until(ExpectedConditions.visibilityOfElementLocated(error));
return this; // we stay on the login page
}
public String errorText() {
return driver.findElement(error).getText();
}
private void fillAndSubmit(String user, String pass) {
driver.findElement(email).sendKeys(user);
driver.findElement(password).sendKeys(pass);
driver.findElement(submit).click();
}
}
// Test: assertions live here, not in the page
@Test
void wrongPasswordShowsError() {
String message = new LoginPage(driver)
.open(BASE_URL)
.loginExpectingError("anna@example.com", "wrong-pass")
.errorText();
assertEquals("Invalid email or password", message);
}
private final By — локатори як незмінні рецепти пошуку, приховані від тестів. Конструктор приймає драйвер ззовні, тож сторінка не створює і не закриває браузер. Публічні методи повертають об’єкт наступної сторінки або this, що дає ланцюжок .open(...).loginExpectingError(...).errorText(). Приватний fillAndSubmit прибирає дублювання між двома сценаріями входу, а assertEquals з JUnit 5 стоїть лише в тесті.private final By keeps locators as immutable search recipes hidden from tests. The constructor takes the driver from outside, so the page never creates or closes a browser. Public methods return the next page object or this, which enables the chain .open(...).loginExpectingError(...).errorText(). The private fillAndSubmit removes duplication between the two login scenarios, and JUnit 5 assertEquals appears only in the test.Що це. PageFactory з модуля selenium-support читає поля класу з анотаціями @FindBy і під час PageFactory.initElements(driver, this) підставляє в них проксі-об’єкти. Реального пошуку в цей момент немає: проксі шукає елемент лише тоді, коли ти викликаєш на ньому метод, і за замовчуванням робить це знову при кожному виклику.
What it is. PageFactory from the selenium-support module reads class fields annotated with @FindBy and, during PageFactory.initElements(driver, this), puts proxy objects into them. No real lookup happens at that moment: the proxy searches for the element only when you call a method on it, and by default it searches again on every call.
Навіщо знати. PageFactory досі трапляється в старих фреймворках, і на співбесіді перевіряють, чи розумієш ти його «магію». Проблема в тому, що вона ховає таймінг: у коді немає видимого моменту пошуку, тож незрозуміло, коли саме впаде NoSuchElementException і чому. Команда Selenium неодноразово радила не використовувати PageFactory, а в офіційній документації про Page Object приклади написано з By.
Why it matters. PageFactory still shows up in older frameworks, and interviewers check whether you understand its magic. The problem is that it hides timing: there is no visible point where the search happens, so it is unclear when and why NoSuchElementException will fire. The Selenium team has repeatedly advised against PageFactory, and the official Page Object documentation writes its examples with By.
Приклад. Поле @FindBy(id = "email") WebElement email виглядає компактно, але не має очікування. AjaxElementLocatorFactory з таймаутом змушує проксі повторювати пошук кілька секунд, проте чекає лише присутності в DOM, а не видимості чи клікабельності. Анотації також не приймають змінних, тож локатор рядка таблиці з динамічним id так не описати.
Example. A field @FindBy(id = "email") WebElement email looks compact but has no waiting at all. AjaxElementLocatorFactory with a timeout makes the proxy retry the lookup for a few seconds, yet it waits only for presence in the DOM, not for visibility or clickability. Annotations also cannot take variables, so a locator for a table row with a dynamic id cannot be expressed this way.
Підводні камені. @CacheLookup кешує знайдений елемент і на сторінках, що перемальовуються, дає stale-винятки. Поля-проксі погано поєднуються з ExpectedConditions, які очікують By, тож команди змішують два стилі. Юніт-тестам та рефакторингу теж заважає рефлексія.
Pitfalls. @CacheLookup caches the found element and causes stale exceptions on pages that re-render. Proxy fields fit poorly with ExpectedConditions, which expect a By, so teams end up mixing two styles. The reflection also gets in the way of refactoring and IDE navigation.
Уточнення. Що замість: звичайні By-поля плюс явні очікування в методах сторінки. Код стає трохи довшим, зате кожен пошук і кожне очікування видно.
Follow-up. What to use instead: plain By fields plus explicit waits inside page methods. The code gets slightly longer, but every lookup and every wait is visible.
// Було: проксі без очікувань, пошук прихований
public class SearchPage {
@FindBy(id = "query")
private WebElement query;
@FindBy(css = "[data-testid='result']")
private List<WebElement> results;
public SearchPage(WebDriver driver) {
PageFactory.initElements(new AjaxElementLocatorFactory(driver, 5), this);
}
}
// Стало: By-поля, явне очікування, динамічний локатор
public class SearchPageExplicit {
private final WebDriverWait wait;
private final By query = By.id("query");
public SearchPageExplicit(WebDriver driver) {
this.wait = new WebDriverWait(driver, Duration.ofSeconds(10));
}
public WebElement result(String sku) {
By row = By.cssSelector("[data-sku='" + sku + "']");
return wait.until(ExpectedConditions.visibilityOfElementLocated(row));
}
}
// Before: proxies with no waits, lookup is hidden
public class SearchPage {
@FindBy(id = "query")
private WebElement query;
@FindBy(css = "[data-testid='result']")
private List<WebElement> results;
public SearchPage(WebDriver driver) {
PageFactory.initElements(new AjaxElementLocatorFactory(driver, 5), this);
}
}
// After: By fields, explicit wait, dynamic locator
public class SearchPageExplicit {
private final WebDriverWait wait;
private final By query = By.id("query");
public SearchPageExplicit(WebDriver driver) {
this.wait = new WebDriverWait(driver, Duration.ofSeconds(10));
}
public WebElement result(String sku) {
By row = By.cssSelector("[data-sku='" + sku + "']");
return wait.until(ExpectedConditions.visibilityOfElementLocated(row));
}
}
@FindBy(id = ...) і @FindBy(css = ...) описують локатор константою в анотації; поле типу List<WebElement> отримує проксі на список. new AjaxElementLocatorFactory(driver, 5) — фабрика локаторів, яка повторює пошук до 5 секунд. У другому класі метод result(String sku) будує By з параметра, чого анотація зробити не може.@FindBy(id = ...) and @FindBy(css = ...) describe a locator as a constant in an annotation; a List<WebElement> field receives a proxy for a list. new AjaxElementLocatorFactory(driver, 5) is a locator factory that retries the lookup for up to 5 seconds. In the second class the method result(String sku) builds a By from a parameter, which an annotation cannot do.Що це. Підготовка даних — усе, що має існувати до перевірки: користувач, його замовлення, залишки на складі, авторизована сесія. Принцип такий: через UI проходимо лише той сценарій, який перевіряємо, а передумови створюємо через API, тестові ендпоїнти або напряму в базі.
What it is. Data setup is everything that must exist before the check: a user, their orders, stock levels, an authenticated session. The principle is to go through the UI only for the scenario under test and to create the preconditions through the API, test endpoints or directly in the database.
Навіщо. Реєстрація через форму займає 15–20 секунд і сама по собі може впасти, тож тест історії замовлень падатиме через баг у реєстрації, хоча перевіряє інше. Виклик API займає десятки мілісекунд. На наборі з 400 тестів різниця вимірюється годинами, а звіт стає чеснішим: кожен тест падає лише зі своєї причини.
Why it matters. Signing up through the form takes 15 to 20 seconds and can fail on its own, so an order history test would fail because of a registration bug while checking something else. An API call takes tens of milliseconds. Across a suite of 400 tests the difference is measured in hours, and the report becomes more honest: each test fails only for its own reason.
Приклад. У @BeforeEach клієнт тестового API створює унікального покупця з email на основі UUID і замовлення для нього, а потім повертає токен сесії. Тест відкриває будь-яку легку сторінку домену, додає cookie сесії і одразу переходить до історії замовлень, пропускаючи форму входу. У @AfterEach дані видаляються тим самим API.
Example. In @BeforeEach a test data API client creates a unique buyer with a UUID-based email and an order for them, then returns a session token. The test opens any lightweight page on the domain, adds the session cookie and goes straight to order history, skipping the login form. In @AfterEach the data is removed through the same API.
Підводні камені. Спільний акаунт test@company.com на весь набір — головне джерело конфліктів у паралелі: один тест скасовує замовлення, яке інший якраз перевіряє. Прямий запис у базу обходить бізнес-логіку і може створити стан, неможливий у продакшені, тому API надійніший. Тестові ендпоїнти мають бути закриті на продакшен-середовищі. І хоча б один тест має проходити реєстрацію та вхід саме через UI.
Pitfalls. One shared test@company.com account for the whole suite is the main source of conflicts in parallel runs: one test cancels an order that another is checking at that moment. Writing straight to the database bypasses business logic and can create a state that is impossible in production, so the API is safer. Test endpoints must be disabled in production. And at least one test should still go through sign-up and login in the UI.
Уточнення. Якщо прибрання не вдалося, допомагають префікси або теги в даних і нічне чищення за віком запису, щоб середовище не заростало сміттям.
Follow-up. If cleanup fails, prefixes or tags on the data plus a nightly job that deletes records by age keep the environment from filling up with junk.
class OrderHistoryTest extends BaseUiTest {
private final TestDataApi api = new TestDataApi(API_URL);
private TestUser buyer;
@BeforeEach
void createData() {
// Унікальний покупець: паралельні тести не перетинаються
buyer = api.createUser("buyer-" + UUID.randomUUID() + "@test.example");
api.createOrder(buyer.id(), "SKU-42", 2);
}
@Test
void newOrderIsShownInHistory() {
driver.get(BASE_URL + "/robots.txt"); // cookie можна додати лише на своєму домені
driver.manage().addCookie(new Cookie("SESSION", api.sessionToken(buyer)));
driver.get(BASE_URL + "/account/orders");
assertEquals(1, new OrdersPage(driver).rowsFor("SKU-42"));
}
@AfterEach
void cleanUp() {
api.deleteUser(buyer.id()); // разом із замовленнями
}
}
class OrderHistoryTest extends BaseUiTest {
private final TestDataApi api = new TestDataApi(API_URL);
private TestUser buyer;
@BeforeEach
void createData() {
// A unique buyer: parallel tests never overlap
buyer = api.createUser("buyer-" + UUID.randomUUID() + "@test.example");
api.createOrder(buyer.id(), "SKU-42", 2);
}
@Test
void newOrderIsShownInHistory() {
driver.get(BASE_URL + "/robots.txt"); // a cookie can be added only on its own domain
driver.manage().addCookie(new Cookie("SESSION", api.sessionToken(buyer)));
driver.get(BASE_URL + "/account/orders");
assertEquals(1, new OrdersPage(driver).rowsFor("SKU-42"));
}
@AfterEach
void cleanUp() {
api.deleteUser(buyer.id()); // together with the orders
}
}
TestDataApi і TestUser — власні класи фреймворку поверх HTTP-клієнта, наприклад REST Assured або java.net.http.HttpClient. UUID.randomUUID() дає унікальний email на кожен запуск. driver.manage().addCookie(new Cookie(name, value)) додає cookie для поточного домену, тому спершу відкривається легка сторінка цього домену. @BeforeEach і @AfterEach з JUnit 5 обгортають кожен тест створенням і прибиранням.TestDataApi and TestUser are the framework's own classes on top of an HTTP client such as REST Assured or java.net.http.HttpClient. UUID.randomUUID() gives a unique email for every run. driver.manage().addCookie(new Cookie(name, value)) adds a cookie for the current domain, which is why a lightweight page of that domain is opened first. JUnit 5 @BeforeEach and @AfterEach wrap every test with creation and cleanup.Що це. Життєвий цикл драйвера — це відповіді на три питання: хто створює браузер, звідки береться конфігурація і хто гарантовано його закриває. У зрілому фреймворку це одне місце: DriverFactory для створення і розширення JUnit 5 або базовий клас для керування часом життя.
What it is. The driver lifecycle comes down to three questions: who creates the browser, where the configuration comes from and who is guaranteed to close it. In a mature framework that is one place: a DriverFactory for creation and a JUnit 5 extension or a base class for managing its lifetime.
Навіщо. Якщо new ChromeDriver() розкидано по тестах, перехід на Grid чи додавання Firefox означає правити сотню файлів. Коли фабрика читає браузер із системної властивості, а адресу Grid зі змінної середовища, той самий код іде на ноутбуці, в CI і на хмарному провайдері без жодної правки тестів.
Why it matters. If new ChromeDriver() is scattered across tests, moving to Grid or adding Firefox means editing a hundred files. When the factory reads the browser from a system property and the Grid address from an environment variable, the same code runs on a laptop, in CI and at a cloud provider without touching a single test.
Приклад. mvn test -Dbrowser=firefox запускає Firefox локально. У CI задано GRID_URL, і фабрика створює RemoteWebDriver з тими самими опціями. Headless, розмір вікна і стратегія завантаження сторінки налаштовуються в одному методі. Розширення кладе драйвер у ExtensionContext.Store перед тестом, передає його параметром і викликає quit() після тесту, навіть якщо той упав.
Example. mvn test -Dbrowser=firefox starts Firefox locally. In CI GRID_URL is set, and the factory creates a RemoteWebDriver with the same options. Headless mode, window size and page load strategy are configured in one method. The extension puts the driver into the ExtensionContext.Store before a test, passes it in as a parameter and calls quit() afterwards, even when the test failed.
Підводні камені. Статичне поле static WebDriver driver ламає паралельний запуск: потоки ділять один браузер. Сінглтон, що живе весь прогін, накопичує cookies і стан між тестами. Якщо quit() викликається в кінці тіла тесту, а не в afterEach, при падінні сесія на Grid висить до таймауту і забирає слот.
Pitfalls. A static WebDriver driver field breaks parallel runs because threads share one browser. A singleton living for the whole run accumulates cookies and state between tests. If quit() is called at the end of the test body rather than in an afterEach hook, a failure leaves the Grid session hanging until timeout and holding a slot.
Уточнення. Питання про швидкість: один браузер на клас замість одного на тест економить час запуску, але вимагає чистити cookies і сховище між тестами, а ізоляція слабшає. Я обираю за замовчуванням браузер на тест.
Follow-up. On speed: one browser per class instead of one per test saves startup time but requires clearing cookies and storage between tests, and isolation gets weaker. My default is a browser per test.
public final class DriverFactory {
public static WebDriver create() throws MalformedURLException {
String browser = System.getProperty("browser", "chrome");
String grid = System.getenv("GRID_URL"); // задано лише в CI
if ("firefox".equals(browser)) {
FirefoxOptions ff = new FirefoxOptions();
ff.addArguments("-headless");
return grid == null ? new FirefoxDriver(ff)
: new RemoteWebDriver(URI.create(grid).toURL(), ff);
}
ChromeOptions ch = new ChromeOptions();
ch.addArguments("--headless=new", "--window-size=1920,1080");
return grid == null ? new ChromeDriver(ch)
: new RemoteWebDriver(URI.create(grid).toURL(), ch);
}
}
public class DriverExtension
implements BeforeEachCallback, AfterEachCallback, ParameterResolver {
private static final ExtensionContext.Namespace NS =
ExtensionContext.Namespace.create(DriverExtension.class);
@Override
public void beforeEach(ExtensionContext ctx) throws Exception {
ctx.getStore(NS).put("driver", DriverFactory.create());
}
@Override
public void afterEach(ExtensionContext ctx) {
WebDriver driver = ctx.getStore(NS).remove("driver", WebDriver.class);
if (driver != null) driver.quit(); // навіть якщо тест упав
}
@Override
public boolean supportsParameter(ParameterContext p, ExtensionContext e) {
return p.getParameter().getType() == WebDriver.class;
}
@Override
public Object resolveParameter(ParameterContext p, ExtensionContext e) {
return e.getStore(NS).get("driver", WebDriver.class);
}
}
public final class DriverFactory {
public static WebDriver create() throws MalformedURLException {
String browser = System.getProperty("browser", "chrome");
String grid = System.getenv("GRID_URL"); // set only in CI
if ("firefox".equals(browser)) {
FirefoxOptions ff = new FirefoxOptions();
ff.addArguments("-headless");
return grid == null ? new FirefoxDriver(ff)
: new RemoteWebDriver(URI.create(grid).toURL(), ff);
}
ChromeOptions ch = new ChromeOptions();
ch.addArguments("--headless=new", "--window-size=1920,1080");
return grid == null ? new ChromeDriver(ch)
: new RemoteWebDriver(URI.create(grid).toURL(), ch);
}
}
public class DriverExtension
implements BeforeEachCallback, AfterEachCallback, ParameterResolver {
private static final ExtensionContext.Namespace NS =
ExtensionContext.Namespace.create(DriverExtension.class);
@Override
public void beforeEach(ExtensionContext ctx) throws Exception {
ctx.getStore(NS).put("driver", DriverFactory.create());
}
@Override
public void afterEach(ExtensionContext ctx) {
WebDriver driver = ctx.getStore(NS).remove("driver", WebDriver.class);
if (driver != null) driver.quit(); // even when the test failed
}
@Override
public boolean supportsParameter(ParameterContext p, ExtensionContext e) {
return p.getParameter().getType() == WebDriver.class;
}
@Override
public Object resolveParameter(ParameterContext p, ExtensionContext e) {
return e.getStore(NS).get("driver", WebDriver.class);
}
}
System.getProperty("browser", "chrome") читає -Dbrowser з Maven чи Gradle зі значенням за замовчуванням. ChromeDriver і FirefoxDriver успадковуються від RemoteWebDriver, тому тернарний оператор повертає спільний тип. ExtensionContext.Store з власним Namespace зберігає драйвер окремо для кожного тесту, ParameterResolver дозволяє писати @Test void t(WebDriver driver), а клас тесту підключає все через @ExtendWith(DriverExtension.class).System.getProperty("browser", "chrome") reads -Dbrowser from Maven or Gradle with a default value. ChromeDriver and FirefoxDriver extend RemoteWebDriver, so the ternary returns a common type. ExtensionContext.Store with its own Namespace keeps the driver separately for each test, ParameterResolver lets you write @Test void t(WebDriver driver), and the test class wires everything with @ExtendWith(DriverExtension.class).Що це. Grid 4 — це набір сервісів, які приймають W3C-запити на нову сесію і розподіляють їх по машинах з браузерами. Router — єдина точка входу на порту 4444. New Session Queue тримає запити на створення сесій, доки не знайдеться вільний слот. Distributor знає всі ноди та їхні слоти і вирішує, куди віддати сесію. Session Map зберігає відповідність «id сесії → нода», щоб Router пересилав наступні команди куди треба. Event Bus — внутрішній канал повідомлень між компонентами. Node реєструється через Event Bus, оголошує слоти (наприклад, чотири Chrome і два Firefox) і запускає браузери з драйверами.
What it is. Grid 4 is a set of services that accept W3C new-session requests and spread them across machines with browsers. The Router is the single entry point on port 4444. The New Session Queue holds session requests until a free slot appears. The Distributor knows every node and its slots and decides where a session goes. The Session Map keeps the "session id to node" mapping so the Router can forward later commands to the right place. The Event Bus is the internal message channel between components. A Node registers over the Event Bus, advertises its slots (say four Chrome and two Firefox) and starts browsers with their drivers.
Навіщо. Grid дає паралельний і кросбраузерний запуск без того, щоб кожен агент CI мав усі браузери. Тест лише знає URL Router, а решту вирішує інфраструктура.
Why it matters. Grid gives parallel and cross-browser runs without installing every browser on every CI agent. The test only knows the Router URL, and the infrastructure decides the rest.
Приклад. Для локальної перевірки достатньо режиму standalone: усі компоненти в одному процесі. Для команди зазвичай беруть hub + кілька node: хаб містить Router, Distributor, Session Map, чергу і Event Bus. Режим distributed, де кожен компонент — окремий процес, потрібен великим інсталяціям на Kubernetes.
Example. For a quick local check standalone mode is enough: every component lives in one process. Teams usually run hub plus several node processes, where the hub bundles the Router, Distributor, Session Map, queue and Event Bus. The distributed mode, with each component as its own process, is for large installations on Kubernetes.
Підводні камені. Нода приймає лише ті запити, чиї капабіліті збігаються з її слотами: попросиш platformName=linux і Safari — запит просто чекатиме в черзі. Ще одна пастка — сприймати Grid як Selenium 3 з хабом: у четвертій версії реєстрація йде через Event Bus на портах 4442 і 4443, і закриті фаєрволом порти дають ноду, яку хаб не бачить.
Pitfalls. A node accepts only requests whose capabilities match its slots: ask for platformName=linux with Safari and the request just sits in the queue. Another trap is treating Grid like the Selenium 3 hub: version 4 registers nodes through the Event Bus on ports 4442 and 4443, and a firewall that blocks them leaves a node the hub never sees.
Уточнення. Стан Grid видно в UI на /ui та через GET /status, який повертає ноди, слоти і зайняті сесії у JSON.
Follow-up. Grid state is visible in the UI at /ui and through GET /status, which returns nodes, slots and busy sessions as JSON.
# усе в одному процесі: зручно локально
java -jar selenium-server-4.x.jar standalone --max-sessions 4
# хаб: Router, Distributor, Session Map, черга, Event Bus
java -jar selenium-server-4.x.jar hub
# нода на іншій машині реєструється через Event Bus (4442/4443)
java -jar selenium-server-4.x.jar node --hub http://grid-hub:4444
# перевірити, які ноди і слоти бачить Grid
curl http://grid-hub:4444/status
# everything in one process: handy locally
java -jar selenium-server-4.x.jar standalone --max-sessions 4
# hub: Router, Distributor, Session Map, queue, Event Bus
java -jar selenium-server-4.x.jar hub
# a node on another machine registers via the Event Bus (4442/4443)
java -jar selenium-server-4.x.jar node --hub http://grid-hub:4444
# check which nodes and slots the Grid can see
curl http://grid-hub:4444/status
standalone, hub або node. --max-sessions обмежує кількість одночасних сесій на цьому процесі. --hub вказує ноді адресу хаба, з якої вона виводить адреси Event Bus. curl .../status повертає JSON зі станом нод і слотів.standalone, hub or node. --max-sessions caps how many sessions this process runs at once. --hub gives the node the hub address, from which it derives the Event Bus addresses. curl .../status returns JSON with the state of nodes and slots.Що це. RemoteWebDriver — реалізація інтерфейсу WebDriver, яка шле ті самі W3C-команди не локальному chromedriver, а на довільну адресу: Grid, standalone-контейнер або хмарний сервіс. ChromeDriver фактично теж його нащадок, тільки сам запускає драйвер поруч. Конструктор приймає URL і об’єкт опцій, наприклад ChromeOptions, з якого формуються капабіліті запиту.
What it is. RemoteWebDriver is an implementation of the WebDriver interface that sends the same W3C commands not to a local chromedriver but to any address: a Grid, a standalone container or a cloud service. ChromeDriver is actually a subclass of it that just starts the driver next to you. The constructor takes a URL and an options object, for example ChromeOptions, from which the request capabilities are built.
Навіщо. Код тестів лишається однаковим, змінюється лише фабрика драйвера: локально — new ChromeDriver(), у CI — new RemoteWebDriver(gridUrl, options). Так легко перемикатись між машиною розробника, Docker і хмарою.
Why it matters. Test code stays the same and only the driver factory changes: locally new ChromeDriver(), in CI new RemoteWebDriver(gridUrl, options). That makes switching between a developer laptop, Docker and a cloud provider cheap.
Приклад. Distributor порівнює капабіліті запиту зі слотами нод: browserName, browserVersion, platformName і префіксовані розширення на кшталт se:. Якщо тест хоче browserVersion=126, а ноди мають лише 127, сесія не стартує. Файли для завантаження теж потребують уваги: шлях у sendKeys вказує на диск машини з тестом, тож потрібен LocalFileDetector, який передає файл на ноду.
Example. The Distributor compares request capabilities with node slots: browserName, browserVersion, platformName and prefixed extensions such as se:. If the test wants browserVersion=126 but the nodes only offer 127, no session starts. File uploads need care too: the path in sendKeys points to the disk of the machine running the test, so you need a LocalFileDetector, which ships the file to the node.
Підводні камені. Без quit() у finally чи @AfterEach сесія висить на ноді до тайм-ауту бездіяльності й займає слот. Інша помилка — забути, що кожна команда тепер іде мережею: цикл із сотнею getText() через далекий Grid помітно гальмує, краще зчитати таблицю одним запитом або через API.
Pitfalls. Without quit() in a finally block or @AfterEach, the session stays on the node until the idle timeout and holds a slot. Another mistake is forgetting that every command now crosses the network: a loop with a hundred getText() calls against a distant Grid gets slow, so read the table in one call or through the API.
Уточнення. Питають і про RemoteWebDriver.builder() — сучасніший спосіб зібрати віддалений драйвер з опціями та адресою, але звичайний конструктор теж цілком коректний.
Follow-up. You may be asked about RemoteWebDriver.builder(), a newer way to assemble a remote driver from options and an address, but the plain constructor is perfectly valid too.
@Test
void uploadsAvatarOnGrid() throws Exception {
ChromeOptions options = new ChromeOptions();
options.setPlatformName("linux"); // капабіліті, за якими шукають слот
RemoteWebDriver driver = new RemoteWebDriver(
new URL("http://grid-hub:4444"), options);
try {
driver.setFileDetector(new LocalFileDetector()); // файл їде з машини тесту на ноду
driver.get("https://shop.example.com/profile");
driver.findElement(By.cssSelector("input[type=file]"))
.sendKeys(Path.of("src/test/resources/avatar.png").toAbsolutePath().toString());
} finally {
driver.quit(); // звільняє слот навіть при падінні
}
}
@Test
void uploadsAvatarOnGrid() throws Exception {
ChromeOptions options = new ChromeOptions();
options.setPlatformName("linux"); // capabilities used to find a slot
RemoteWebDriver driver = new RemoteWebDriver(
new URL("http://grid-hub:4444"), options);
try {
driver.setFileDetector(new LocalFileDetector()); // file travels from test machine to node
driver.get("https://shop.example.com/profile");
driver.findElement(By.cssSelector("input[type=file]"))
.sendKeys(Path.of("src/test/resources/avatar.png").toAbsolutePath().toString());
} finally {
driver.quit(); // frees the slot even on failure
}
}
new RemoteWebDriver(URL, options) відкриває сесію на Grid за адресою Router. setPlatformName додає капабіліті platformName. setFileDetector(new LocalFileDetector()) змушує клієнт упізнавати локальні шляхи в sendKeys і завантажувати файл на ноду. Блок try/finally гарантує quit() за будь-якого результату.new RemoteWebDriver(URL, options) opens a session on Grid through the Router address. setPlatformName adds the platformName capability. setFileDetector(new LocalFileDetector()) makes the client recognise local paths in sendKeys and upload the file to the node. The try/finally block guarantees quit() whatever the outcome.Що це. Драйвер Chromium-браузера зібраний під конкретну мажорну версію. Якщо на агенті Chrome 128, а в PATH лежить chromedriver 126, сесія не створюється: тест падає ще на конструкторі з SessionNotCreatedException і повідомленням на кшталт This version of ChromeDriver only supports Chrome version 126. Current browser version is 128. Firefox з geckodriver толерантніший, але й там є мінімальні версії.
What it is. A Chromium driver is built for one major browser version. If the agent has Chrome 128 and chromedriver 126 sits in PATH, no session is created: the test fails right in the constructor with SessionNotCreatedException and a message like This version of ChromeDriver only supports Chrome version 126. Current browser version is 128. Firefox with geckodriver is more tolerant, but it still has minimum versions.
Навіщо. Це одна з найчастіших причин «вчора все працювало»: браузер на агенті тихо оновився сам, а драйвер лишився старим. Падіння масове й раптове, тож його треба впізнавати за хвилину, а не шукати баг у застосунку.
Why it matters. This is one of the most common causes of "it worked yesterday": the browser on the agent quietly updated itself while the driver stayed old. The failure is sudden and hits every test, so you want to recognise it within a minute instead of hunting for an application bug.
Приклад. З Selenium 4.6 проблему здебільшого знімає Selenium Manager: він визначає встановлений браузер і завантажує відповідний драйвер у кеш ~/.cache/selenium. Для відтворюваності версію краще зафіксувати через options.setBrowserVersion("stable") або конкретне число — тоді Manager підтягне Chrome for Testing саме цієї версії, незалежно від того, що встановлено в системі.
Example. Since Selenium 4.6, Selenium Manager removes most of the pain: it detects the installed browser and downloads the matching driver into the ~/.cache/selenium cache. For reproducible runs, pin the version with options.setBrowserVersion("stable") or a specific number, and Manager fetches that exact Chrome for Testing build regardless of what the system has installed.
Підводні камені. Старий chromedriver, покладений вручну в PATH або прописаний через webdriver.chrome.driver, має пріоритет над Selenium Manager і повертає проблему. Закриті корпоративним проксі агенти не можуть завантажити драйвер, тож потрібен дзеркальний репозиторій або готовий образ. На Grid у Docker пари браузер-драйвер уже узгоджені в образі selenium/node-chrome з конкретним тегом, тому тег варто фіксувати, а не брати latest.
Pitfalls. An old chromedriver dropped into PATH by hand, or set through webdriver.chrome.driver, wins over Selenium Manager and brings the problem back. Agents behind a corporate proxy cannot download a driver, so they need a mirror or a prebuilt image. On a Docker Grid the browser and driver pair is already matched inside a selenium/node-chrome image with a specific tag, which is why you pin the tag instead of using latest.
Уточнення. Якщо спитають, як швидко діагностувати, — дивлюсь капабіліті browserVersion у відповіді сесії та версію драйвера в лозі старту.
Follow-up. If asked how to diagnose it quickly, I check the browserVersion capability returned by the session and the driver version in the startup log.
ChromeOptions options = new ChromeOptions();
options.setBrowserVersion("127"); // Selenium Manager завантажить Chrome for Testing 127 і драйвер до нього
WebDriver driver = new ChromeDriver(options);
Capabilities caps = ((HasCapabilities) driver).getCapabilities();
// у лог CI: яку версію реально отримали
System.out.println("browser " + caps.getBrowserVersion());
ChromeOptions options = new ChromeOptions();
options.setBrowserVersion("127"); // Selenium Manager fetches Chrome for Testing 127 and its driver
WebDriver driver = new ChromeDriver(options);
Capabilities caps = ((HasCapabilities) driver).getCapabilities();
// to the CI log: which version we actually got
System.out.println("browser " + caps.getBrowserVersion());
setBrowserVersion записує капабіліті browserVersion; значення може бути числом або каналом (stable, beta). Коли драйвер не вказано вручну, Selenium Manager підбирає і браузер, і драйвер. HasCapabilities дає доступ до капабіліті, які повернув драйвер, а getBrowserVersion() читає фактичну версію.setBrowserVersion sets the browserVersion capability; the value can be a number or a channel (stable, beta). When no driver is configured by hand, Selenium Manager resolves both the browser and the driver. HasCapabilities exposes the capabilities the driver returned, and getBrowserVersion() reads the actual version.Що це. Паралельний запуск означає, що кілька тестів одночасно виконуються в різних потоках, і кожен має працювати зі своєю сесією браузера. WebDriver не потокобезпечний: якщо два потоки ділять одне статичне поле driver, кліки одного тесту потрапляють у вікно іншого. ThreadLocal<WebDriver> дає кожному потоку власну копію змінної, тож Page Object-и, які беруть драйвер через DriverHolder.get(), автоматично отримують «свій» браузер.
What it is. Parallel execution means several tests run at the same time on different threads, and each one must work with its own browser session. WebDriver is not thread-safe: if two threads share one static driver field, clicks from one test land in the other test's window. ThreadLocal<WebDriver> gives every thread its own copy of the variable, so page objects that get the driver through DriverHolder.get() automatically receive "their" browser.
Навіщо. Набір на 400 UI-тестів у одному потоці йде годину; на восьми потоках через Grid — близько десяти хвилин. Без ізоляції драйверів і даних ця економія перетворюється на хаотичні падіння.
Why it matters. A suite of 400 UI tests on one thread takes an hour; on eight threads against a Grid it takes about ten minutes. Without isolated drivers and data those savings turn into random failures.
Приклад. У JUnit 5 паралельність вмикається у junit-platform.properties: junit.jupiter.execution.parallel.enabled=true, режим junit.jupiter.execution.parallel.mode.default=concurrent і кількість потоків через стратегію fixed. У TestNG те саме задають у testng.xml: parallel="methods" і thread-count="8". Драйвер створюється в @BeforeEach, закривається в @AfterEach.
Example. In JUnit 5 you enable it in junit-platform.properties: junit.jupiter.execution.parallel.enabled=true, the mode junit.jupiter.execution.parallel.mode.default=concurrent, and the thread count through the fixed strategy. In TestNG the same lives in testng.xml: parallel="methods" and thread-count="8". The driver is created in @BeforeEach and closed in @AfterEach.
Підводні камені. Після quit() обов’язково викликати remove(): потоки пулу перевикористовуються, і наступний тест інакше отримає мертву сесію. Друга пастка — спільні дані: два потоки, що логіняться тим самим користувачем чи редагують той самий кошик, заважають один одному, тож кожен тест створює власні дані з унікальними іменами. Кількість потоків не повинна перевищувати кількість слотів Grid, інакше запити стоять у черзі.
Pitfalls. Call remove() after quit(): pool threads are reused, and otherwise the next test gets a dead session. The second trap is shared data: two threads logging in as the same user or editing the same cart get in each other's way, so each test creates its own data with unique names. The thread count should not exceed the Grid slots, or requests wait in the queue.
Уточнення. Часто питають про статичні Page Object-и: їх теж не можна ділити між потоками, якщо вони тримають драйвер у полі.
Follow-up. A common follow-up is about static page objects: they cannot be shared between threads either if they keep the driver in a field.
public final class DriverHolder {
private static final ThreadLocal<WebDriver> DRIVER = new ThreadLocal<>();
public static void start() { DRIVER.set(new ChromeDriver()); }
public static WebDriver get() { return DRIVER.get(); }
public static void stop() {
WebDriver driver = DRIVER.get();
if (driver != null) {
driver.quit();
}
DRIVER.remove(); // потік із пулу не отримає мертву сесію
}
}
abstract class BaseUiTest {
@BeforeEach void openBrowser() { DriverHolder.start(); }
@AfterEach void closeBrowser() { DriverHolder.stop(); }
}
// src/test/resources/junit-platform.properties:
// junit.jupiter.execution.parallel.enabled=true
// junit.jupiter.execution.parallel.mode.default=concurrent
// junit.jupiter.execution.parallel.config.strategy=fixed
// junit.jupiter.execution.parallel.config.fixed.parallelism=8
public final class DriverHolder {
private static final ThreadLocal<WebDriver> DRIVER = new ThreadLocal<>();
public static void start() { DRIVER.set(new ChromeDriver()); }
public static WebDriver get() { return DRIVER.get(); }
public static void stop() {
WebDriver driver = DRIVER.get();
if (driver != null) {
driver.quit();
}
DRIVER.remove(); // a pooled thread will not inherit a dead session
}
}
abstract class BaseUiTest {
@BeforeEach void openBrowser() { DriverHolder.start(); }
@AfterEach void closeBrowser() { DriverHolder.stop(); }
}
// src/test/resources/junit-platform.properties:
// junit.jupiter.execution.parallel.enabled=true
// junit.jupiter.execution.parallel.mode.default=concurrent
// junit.jupiter.execution.parallel.config.strategy=fixed
// junit.jupiter.execution.parallel.config.fixed.parallelism=8
ThreadLocal<WebDriver> зберігає окреме значення для кожного потоку; set, get і remove працюють лише з копією поточного потоку. @BeforeEach та @AfterEach виконуються в тому ж потоці, що й сам тест. Властивості в коментарі вмикають паралельне виконання, режим concurrent і фіксований пул із восьми потоків.ThreadLocal<WebDriver> stores a separate value per thread; set, get and remove only touch the current thread's copy. @BeforeEach and @AfterEach run on the same thread as the test itself. The properties in the comment turn on parallel execution, the concurrent mode and a fixed pool of eight threads.Що це. Проєкт docker-selenium публікує готові образи: selenium/hub, selenium/node-chrome, selenium/node-firefox, selenium/standalone-chrome, а також selenium/node-docker для динамічного Grid і selenium/video для запису екрана. У кожному образі браузер і драйвер уже узгоджені, тож окремо керувати версіями не треба.
What it is. The docker-selenium project publishes ready images: selenium/hub, selenium/node-chrome, selenium/node-firefox, selenium/standalone-chrome, plus selenium/node-docker for a dynamic Grid and selenium/video for screen recording. Every image ships a browser and driver that already match, so versions need no separate management.
Навіщо. Контейнери роблять середовище однаковим на ноутбуці й в CI: той самий тег образу означає той самий браузер. Нода піднімається за секунди і не залежить від того, що встановлено на агенті.
Why it matters. Containers make the environment identical on a laptop and in CI: the same image tag means the same browser. A node comes up in seconds and does not care what is installed on the agent.
Приклад. Для CI часто вистачає одного selenium/standalone-chrome як сервісу поруч із джобою: тести ходять на http://localhost:4444. Для команди — docker compose з хабом і нодами; ноді передають адресу Event Bus змінними SE_EVENT_BUS_HOST, SE_EVENT_BUS_PUBLISH_PORT і SE_EVENT_BUS_SUBSCRIBE_PORT. Динамічний Grid на node-docker стартує новий контейнер з браузером під кожну сесію і прибирає його після quit(), тож сесії не впливають одна на одну.
Example. In CI a single selenium/standalone-chrome running as a service next to the job is often enough, and tests go to http://localhost:4444. For a team there is a docker compose file with a hub and nodes; each node gets the Event Bus address through SE_EVENT_BUS_HOST, SE_EVENT_BUS_PUBLISH_PORT and SE_EVENT_BUS_SUBSCRIBE_PORT. A dynamic Grid built on node-docker starts a fresh browser container for every session and removes it after quit(), so sessions never affect each other.
Підводні камені. Chrome у контейнері активно використовує /dev/shm, а за замовчуванням Docker дає лише 64 МБ — вкладки падають з «Aw, Snap!». Тому ноді задають shm_size: 2gb. Тег latest тихо змінює версію браузера між прогонами; краще фіксувати конкретний тег. Для динамічного Grid потрібен доступ до Docker-сокета, що в спільному CI є питанням безпеки.
Pitfalls. Chrome in a container leans heavily on /dev/shm, and Docker gives only 64 MB by default, so tabs crash with "Aw, Snap!". That is why a node gets shm_size: 2gb. The latest tag silently changes the browser version between runs, so pin a specific tag. A dynamic Grid needs access to the Docker socket, which is a security question on shared CI runners.
Уточнення. Питають, як дивитись на тест, що впав у контейнері: образи мають noVNC на порту 7900, а контейнер selenium/video записує відео сесії.
Follow-up. People ask how to watch a test that failed inside a container: the images expose noVNC on port 7900, and a selenium/video container records the session.
services:
selenium-hub:
image: selenium/hub:4.25.0 # фіксований тег, не latest
ports:
- "4444:4444" # Router: сюди ходять тести
chrome:
image: selenium/node-chrome:4.25.0
shm_size: 2gb # інакше вкладки Chrome падають
depends_on: [selenium-hub]
environment:
- SE_EVENT_BUS_HOST=selenium-hub
- SE_EVENT_BUS_PUBLISH_PORT=4442
- SE_EVENT_BUS_SUBSCRIBE_PORT=4443
- SE_NODE_MAX_SESSIONS=2
firefox:
image: selenium/node-firefox:4.25.0
shm_size: 2gb
depends_on: [selenium-hub]
environment:
- SE_EVENT_BUS_HOST=selenium-hub
- SE_EVENT_BUS_PUBLISH_PORT=4442
- SE_EVENT_BUS_SUBSCRIBE_PORT=4443
services:
selenium-hub:
image: selenium/hub:4.25.0 # a pinned tag, not latest
ports:
- "4444:4444" # Router: tests connect here
chrome:
image: selenium/node-chrome:4.25.0
shm_size: 2gb # otherwise Chrome tabs crash
depends_on: [selenium-hub]
environment:
- SE_EVENT_BUS_HOST=selenium-hub
- SE_EVENT_BUS_PUBLISH_PORT=4442
- SE_EVENT_BUS_SUBSCRIBE_PORT=4443
- SE_NODE_MAX_SESSIONS=2
firefox:
image: selenium/node-firefox:4.25.0
shm_size: 2gb
depends_on: [selenium-hub]
environment:
- SE_EVENT_BUS_HOST=selenium-hub
- SE_EVENT_BUS_PUBLISH_PORT=4442
- SE_EVENT_BUS_SUBSCRIBE_PORT=4443
services описує три контейнери в одній мережі, тож нода звертається до хаба за іменем selenium-hub. ports відкриває 4444 назовні для тестів. Змінні SE_EVENT_BUS_* кажуть ноді, де Event Bus, а SE_NODE_MAX_SESSIONS задає кількість слотів. shm_size збільшує спільну пам’ять контейнера.services declares three containers on one network, so a node reaches the hub by the name selenium-hub. ports exposes 4444 to the tests. The SE_EVENT_BUS_* variables tell the node where the Event Bus is, and SE_NODE_MAX_SESSIONS sets the number of slots. shm_size enlarges the container's shared memory.Що це. Кожен запит на нову сесію спершу потрапляє в New Session Queue і чекає, доки Distributor знайде вільний слот з потрібними капабіліті. Якщо слот не звільнився за --session-request-timeout (за замовчуванням 300 секунд), тест отримує SessionNotCreatedException з текстом про тайм-аут запиту нової сесії. У звіті це виглядає як «впав на старті через п’ять хвилин», хоча застосунок ні до чого.
What it is. Every new-session request first lands in the New Session Queue and waits until the Distributor finds a free slot with matching capabilities. If no slot frees up within --session-request-timeout (300 seconds by default), the test gets a SessionNotCreatedException saying the new session request timed out. In the report it reads as "failed at startup after five minutes", even though the application has nothing to do with it.
Навіщо. Помилка в розрахунку ємності дає або черги й тайм-аути, або перевантажені ноди з повільними, флакі тестами. Обидва сценарії виглядають як «нестабільні тести», тож senior має вміти відрізнити їх від проблем коду.
Why it matters. Getting capacity wrong gives you either queues and timeouts, or overloaded nodes with slow and flaky tests. Both look like "unstable tests", so a senior engineer must tell them apart from real code problems.
Приклад. За замовчуванням нода виставляє стільки слотів, скільки має ядер CPU. Робоча оцінка — одна сесія Chrome на ядро і приблизно 1–2 ГБ пам’яті. Якщо CI запускає три джоби по вісім потоків, а Grid має 16 слотів, вісім запитів постійно чекають. Розв’язки: зменшити паралельність до кількості слотів, додати ноди або масштабувати їх автоматично — на Kubernetes для цього є скейлер KEDA для Selenium Grid, який дивиться на довжину черги.
Example. By default a node offers as many slots as it has CPU cores. A working estimate is one Chrome session per core and about 1–2 GB of memory. If CI runs three jobs with eight threads each against a Grid with 16 slots, eight requests are always waiting. The fixes: cut parallelism to the slot count, add nodes, or scale them automatically; on Kubernetes the KEDA scaler for Selenium Grid does this by watching the queue length.
Підводні камені. --override-max-sessions дозволяє виставити слотів більше, ніж ядер, але тоді тести стають повільнішими й нестабільнішими, а не швидшими. Ще гірше — витоки сесій: тест без quit() тримає слот до --session-timeout бездіяльності (теж 300 секунд), і черга росте, хоча реально нічого не виконується.
Pitfalls. --override-max-sessions lets a node advertise more slots than cores, but tests then get slower and flakier, not faster. Session leaks are worse: a test without quit() holds a slot until the --session-timeout idle limit (also 300 seconds), and the queue grows while nothing is actually running.
Уточнення. Спитають, що моніторити: довжину черги, кількість вільних слотів і тривалість старту сесії — усе це доступно через /status і GraphQL-ендпоінт /graphql.
Follow-up. You may be asked what to monitor: queue length, free slots and session start time, all available through /status and the /graphql endpoint.
// Перевірка перед великим прогоном: чи вистачить слотів
HttpClient http = HttpClient.newHttpClient();
String query = "{\"query\": \"{ grid { maxSession sessionCount sessionQueueSize } }\"}";
HttpRequest request = HttpRequest.newBuilder(URI.create("http://grid-hub:4444/graphql"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(query))
.build();
String body = http.send(request, HttpResponse.BodyHandlers.ofString()).body();
// напр.: {"data":{"grid":{"maxSession":16,"sessionCount":16,"sessionQueueSize":8}}}
System.out.println(body);
// Check before a big run: are there enough slots?
HttpClient http = HttpClient.newHttpClient();
String query = "{\"query\": \"{ grid { maxSession sessionCount sessionQueueSize } }\"}";
HttpRequest request = HttpRequest.newBuilder(URI.create("http://grid-hub:4444/graphql"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(query))
.build();
String body = http.send(request, HttpResponse.BodyHandlers.ofString()).body();
// e.g.: {"data":{"grid":{"maxSession":16,"sessionCount":16,"sessionQueueSize":8}}}
System.out.println(body);
java.net.http.HttpClient, без Selenium. GraphQL-запит до /graphql повертає поля maxSession (усього слотів), sessionCount (зайнято) і sessionQueueSize (запитів у черзі). Лапки всередині JSON екрановано через \", бо тіло — рядковий літерал Java.java.net.http.HttpClient, no Selenium involved. The GraphQL query to /graphql returns maxSession (total slots), sessionCount (busy) and sessionQueueSize (requests waiting). Quotes inside the JSON are escaped as \" because the body is a Java string literal.Що це. Логи консолі — це повідомлення, які сторінка пише через console.error, а також неперехоплені винятки JavaScript і помилки завантаження ресурсів. У Chrome їх можна отримати через класичний механізм логування: у капабіліті goog:loggingPrefs передати LoggingPreferences з рівнем для LogType.BROWSER, а після дії викликати driver.manage().logs().get(LogType.BROWSER).
What it is. Console logs are the messages a page writes through console.error, plus uncaught JavaScript exceptions and failed resource loads. In Chrome you read them through the classic logging mechanism: pass LoggingPreferences with a level for LogType.BROWSER in the goog:loggingPrefs capability, then call driver.manage().logs().get(LogType.BROWSER) after an action.
Навіщо. Багато клієнтських багів не видно на екрані одразу: кнопка є, але обробник упав з TypeError, і наступний крок тесту згодом зламається з незрозумілим таймаутом. Якщо після ключових кроків перевіряти, що в консолі немає записів рівня SEVERE, тест падає ближче до причини, а в звіті одразу є стек помилки.
Why it matters. Plenty of client-side bugs are not visible right away: the button is there, but its handler died with a TypeError, and a later step fails with a puzzling timeout. If you check after key steps that the console has no SEVERE entries, the test fails close to the cause and the report already holds the stack trace.
Приклад. На сторінці кошика фронтенд звертається до поля undefined.price. Візуально сторінка майже ціла, але підсумок не перераховується. Тест з перевіркою консолі падає з повідомленням Console errors: [SEVERE ... Cannot read properties of undefined] — це вже готовий опис для розробника.
Example. On the cart page the frontend reads undefined.price. The page looks almost fine, yet the total never recalculates. A test with a console check fails with Console errors: [SEVERE ... Cannot read properties of undefined], which is a ready-made description for the developer.
Підводні камені. Цей механізм не входить до стандарту W3C: він працює в Chrome і Edge, а geckodriver ендпойнт логів не підтримує. ChromeDriver віддає буфер і очищає його, тож другий виклик поверне лише нові записи. Сторонні скрипти аналітики теж пишуть помилки, тому потрібен allowlist відомого шуму, інакше команда почне ігнорувати червоні тести.
Pitfalls. This mechanism is not part of the W3C standard: it works in Chrome and Edge, while geckodriver does not support the logs endpoint. ChromeDriver hands over its buffer and clears it, so a second call returns only new entries. Third-party analytics scripts log errors too, so you need an allowlist of known noise, or the team will learn to ignore red builds.
Уточнення. Кросбраузерний напрям — WebDriver BiDi: з капабіліті webSocketUrl браузер сам надсилає події консолі через WebSocket, і так можна підписатись на них у Chrome і Firefox, а не опитувати буфер після факту.
Follow-up. The cross-browser direction is WebDriver BiDi: with the webSocketUrl capability the browser pushes console events over a WebSocket, so you can subscribe to them in Chrome and Firefox instead of polling a buffer after the fact.
ChromeOptions options = new ChromeOptions();
LoggingPreferences prefs = new LoggingPreferences();
prefs.enable(LogType.BROWSER, Level.ALL); // java.util.logging.Level
options.setCapability("goog:loggingPrefs", prefs);
WebDriver driver = new ChromeDriver(options);
driver.get("https://shop.example.com/cart");
// Забираємо буфер консолі і лишаємо тільки помилки
List<LogEntry> errors = driver.manage().logs().get(LogType.BROWSER)
.getAll().stream()
.filter(e -> e.getLevel() == Level.SEVERE)
.filter(e -> !e.getMessage().contains("analytics")) // відомий шум
.toList();
assertTrue(errors.isEmpty(), () -> "Console errors: " + errors);
ChromeOptions options = new ChromeOptions();
LoggingPreferences prefs = new LoggingPreferences();
prefs.enable(LogType.BROWSER, Level.ALL); // java.util.logging.Level
options.setCapability("goog:loggingPrefs", prefs);
WebDriver driver = new ChromeDriver(options);
driver.get("https://shop.example.com/cart");
// Drain the console buffer and keep only errors
List<LogEntry> errors = driver.manage().logs().get(LogType.BROWSER)
.getAll().stream()
.filter(e -> e.getLevel() == Level.SEVERE)
.filter(e -> !e.getMessage().contains("analytics")) // known noise
.toList();
assertTrue(errors.isEmpty(), () -> "Console errors: " + errors);
LoggingPreferences.enable задає, які типи логів і з якого рівня збирати. logs().get(...) повертає LogEntries, а getAll() — список LogEntry з рівнем, часом і текстом. Stream-фільтри відкидають усе, крім SEVERE, і відомий шум. Третій аргумент assertTrue — лямбда-постачальник повідомлення, тож рядок будується лише при падінні.LoggingPreferences.enable says which log types to collect and from which level. logs().get(...) returns LogEntries, and getAll() gives a list of LogEntry objects with a level, timestamp and message. The stream filters drop everything except SEVERE and known noise. The last argument of assertTrue is a message supplier lambda, so the string is built only when the check fails.Що це. Chrome DevTools Protocol — внутрішній протокол Chromium, через який працює сам DevTools. Selenium 4 відкриває до нього доступ: драйвер, що реалізує HasDevTools, дає DevTools, у якому викликають createSession() і шлють команди доменів Network, Emulation, Log. Поверх цього є високорівневі класи, наприклад NetworkInterceptor, що перехоплює запити за правилами Route. WebDriver BiDi — стандартизований W3C-наступник тієї ж ідеї, який працює і в Firefox.
What it is. The Chrome DevTools Protocol is Chromium's internal protocol, the one DevTools itself runs on. Selenium 4 exposes it: a driver that implements HasDevTools gives you a DevTools object, you call createSession() and send commands from domains such as Network, Emulation or Log. On top of that sit higher-level helpers like NetworkInterceptor, which intercepts requests according to Route rules. WebDriver BiDi is the standardised W3C successor of the same idea and also works in Firefox.
Навіщо. Стани помилок — 500 від сервісу цін, повільна відповідь, порожній список — складно відтворити на реальному стенді. Перехоплення дозволяє віддати браузеру штучну відповідь і детерміновано перевірити, що UI показує банер, а не вічний спінер.
Why it matters. Error states such as a 500 from the pricing service, a slow response or an empty list are hard to reproduce on a real environment. Interception lets you hand the browser a fake response and check deterministically that the UI shows a banner rather than an endless spinner.
Приклад. Правило в коді нижче ловить усі запити до /api/prices і повертає 500 з JSON-тілом. Тест відкриває каталог і чекає на банер з текстом про недоступні ціни. Коли блок try закінчується, перехоплення знімається.
Example. The rule in the code below catches every request to /api/prices and returns a 500 with a JSON body. The test opens the catalogue and waits for the banner saying prices are unavailable. When the try block ends, the interception is removed.
Підводні камені. CDP є лише в Chrome та Edge, а Java-класи згенеровані під конкретну версію протоколу: пакети org.openqa.selenium.devtools.v1NN треба оновлювати разом із браузером, і на свіжому Chrome старий артефакт може не знайтись. На Grid CDP іде через WebSocket ноди, тож мережа між тестом і нодою має це пропускати. Перехоплення змінює поведінку браузера — тримай такі тести окремо від end-to-end перевірок реальної інтеграції.
Pitfalls. CDP exists only in Chrome and Edge, and the Java classes are generated for a specific protocol version: the org.openqa.selenium.devtools.v1NN packages must be upgraded together with the browser, and a fresh Chrome may have no matching artefact in an old Selenium. On Grid, CDP travels over the node's WebSocket, so the network between the test and the node must allow it. Interception changes browser behaviour, so keep these tests apart from end-to-end checks of the real integration.
Уточнення. Питання «CDP чи BiDi для нового коду?» я закриваю так: для кросбраузерних речей — BiDi, бо це стандарт і Selenium поступово переносить туди логи, мережу та скрипти; CDP лишається для Chromium-специфіки на кшталт емуляції мережі.
Follow-up. When asked "CDP or BiDi for new code?", I answer: BiDi for cross-browser needs, because it is the standard and Selenium is moving logs, network and scripting onto it; CDP stays for Chromium-specific work such as network throttling.
ChromeDriver driver = new ChromeDriver();
// Усі запити до /api/prices отримують 500 замість реальної відповіді
Route pricesDown = Route.matching(req -> req.getUri().contains("/api/prices"))
.to(() -> req -> new HttpResponse()
.setStatus(500)
.addHeader("Content-Type", "application/json")
.setContent(Contents.utf8String("{\"error\":\"unavailable\"}")));
try (NetworkInterceptor interceptor = new NetworkInterceptor(driver, pricesDown)) {
driver.get("https://shop.example.com/catalog");
WebElement banner = new WebDriverWait(driver, Duration.ofSeconds(5))
.until(ExpectedConditions.visibilityOfElementLocated(
By.cssSelector("[data-testid='prices-error']")));
assertEquals("Prices are temporarily unavailable", banner.getText());
} // тут перехоплення знімається
ChromeDriver driver = new ChromeDriver();
// Every request to /api/prices gets a 500 instead of the real response
Route pricesDown = Route.matching(req -> req.getUri().contains("/api/prices"))
.to(() -> req -> new HttpResponse()
.setStatus(500)
.addHeader("Content-Type", "application/json")
.setContent(Contents.utf8String("{\"error\":\"unavailable\"}")));
try (NetworkInterceptor interceptor = new NetworkInterceptor(driver, pricesDown)) {
driver.get("https://shop.example.com/catalog");
WebElement banner = new WebDriverWait(driver, Duration.ofSeconds(5))
.until(ExpectedConditions.visibilityOfElementLocated(
By.cssSelector("[data-testid='prices-error']")));
assertEquals("Prices are temporarily unavailable", banner.getText());
} // interception is removed here
Route.matching приймає предикат над HttpRequest, а to — постачальника обробника, який з запиту робить HttpResponse. Contents.utf8String пакує рядок у тіло відповіді. NetworkInterceptor реалізує Closeable, тому try-with-resources гарантує, що правило зникне навіть при падінні асерту.Route.matching takes a predicate over HttpRequest, and to takes a supplier of a handler that turns the request into an HttpResponse. Contents.utf8String wraps a string as the response body. NetworkInterceptor implements Closeable, so try-with-resources guarantees the rule is gone even when the assertion fails.Що це. Флейкі-тест дає різний результат на тому самому коді. Це симптом, а не діагноз: причина може бути в тесті, у застосунку або в середовищі, і задача — довести, де саме.
What it is. A flaky test gives different results on the same code. That is a symptom, not a diagnosis: the cause may be in the test, the application or the environment, and the job is to prove which.
Навіщо. Retry ховає проблему, а іноді й реальний баг: гонка в UI, яку ловить тест, так само трапляється в користувачів. Крім того, кожен повтор подовжує пайплайн і вчить команду не довіряти червоному.
Why it matters. A retry hides the problem and sometimes hides a real bug: a UI race that the test hits also happens to users. On top of that, every rerun makes the pipeline longer and teaches the team not to trust a red build.
Приклад. Спершу відтворюю: запускаю тест 50 разів через @RepeatedTest, у тому ж режимі, що й CI — headless, той самий розмір вікна, на Grid, якщо там падає. Потім збираю артефакти кожного падіння: скріншот, page source, логи консолі, відео сесії з Grid, таймінги запитів. Далі класифікую: таймінг (читаємо стан до завершення XHR), дані (товар розпродав сусідній тест), порядок (тест живе на cookie попереднього), середовище (повільний стенд, інша локаль). У прикладі нижче причина — лічильник кошика читається до відповіді сервера, і виправлення — чекати саме цього стану.
Example. First I reproduce it: run the test 50 times with @RepeatedTest in the same mode as CI, headless, same window size, on Grid if that is where it fails. Then I collect artefacts for each failure: screenshot, page source, console logs, the Grid session video and request timings. Next I classify: timing (we read the state before the XHR finished), data (a neighbouring test bought the last item), order (the test relies on a cookie from the previous one), environment (slow stage, different locale). In the example below the cart counter is read before the server responds, and the fix is to wait for exactly that state.
Підводні камені. Локально тест часто зелений, бо машина швидша; не роби висновок «у мене працює», поки не повториш умови CI. Збільшити таймаут — не фікс, якщо умова очікування неправильна. І не лагодь кілька гіпотез одночасно: інакше не дізнаєшся, що допомогло.
Pitfalls. Locally the test is often green because the machine is faster, so do not conclude "works on my machine" until you have recreated CI conditions. Raising the timeout is not a fix when the wait condition is wrong. And never try several hypotheses at once, or you will not know which one helped.
Уточнення. Якщо причина в застосунку, наприклад подвійний сабміт через гонку, я заводжу баг з відео й логами і не маскую його в тесті.
Follow-up. If the cause is in the product, for example a double submit caused by a race, I file a bug with the video and logs and do not mask it in the test.
// Крок 1: відтворити в умовах CI (headless, той самий розмір вікна)
@RepeatedTest(50)
void cartBadgeShowsOneItem() {
catalogPage.open().addToCart("sku-123");
// Флейкі-версія: читає лічильник до завершення XHR
// assertEquals("1", driver.findElement(By.id("cart-count")).getText());
// Виправлення: чекаємо стан, якого чекає і користувач
new WebDriverWait(driver, Duration.ofSeconds(10))
.until(ExpectedConditions.textToBe(By.id("cart-count"), "1"));
}
// Step 1: reproduce under CI conditions (headless, same window size)
@RepeatedTest(50)
void cartBadgeShowsOneItem() {
catalogPage.open().addToCart("sku-123");
// Flaky version: reads the counter before the XHR has finished
// assertEquals("1", driver.findElement(By.id("cart-count")).getText());
// Fix: wait for the state the user is waiting for too
new WebDriverWait(driver, Duration.ofSeconds(10))
.until(ExpectedConditions.textToBe(By.id("cart-count"), "1"));
}
@RepeatedTest(50) з JUnit 5 запускає метод 50 разів як окремі тести, тож у звіті видно частоту падінь. Закоментований рядок — типовий флейк: getText() знімає значення миттєво. ExpectedConditions.textToBe повторює перевірку тексту, доки він не стане «1» або не мине таймаут.@RepeatedTest(50) from JUnit 5 runs the method 50 times as separate tests, so the report shows the failure rate. The commented-out line is a typical flake: getText() takes the value instantly. ExpectedConditions.textToBe keeps re-checking the text until it becomes "1" or the timeout runs out.Що це. Це вже не питання одного тесту, а проблема довіри до сигналу. Лагодити 60 тестів навмання довго; потрібні вимірювання, правила і власники.
What it is. At this point it is not about one test but about trust in the signal. Fixing sixty tests at random takes forever; you need measurement, rules and owners.
Навіщо. Поки червоний білд нічого не означає, ніхто не дивиться на справжні регресії, і автотести перетворюються на витрати без користі. Мета — щоб падіння знову означало «щось зламалось».
Why it matters. While a red build means nothing, nobody looks at real regressions and the automation becomes cost without value. The goal is for a failure to mean "something broke" again.
Приклад. Крок 1 — метрика: з історії CI рахую частку флейків для кожного тесту за два тижні і сортую за шкодою. Крок 2 — карантин: тести з частотою флейку понад поріг отримують тег і біжать окремою джобою, яка не блокує мердж, але кожен має тікет, власника і дедлайн. Крок 3 — retry тільки один і тільки як сигнал: тест, що пройшов з другої спроби, позначається як flaky у звіті, а не як зелений. Крок 4 — системні фікси, які прибирають цілі класи проблем: видалити Thread.sleep і implicit wait, перейти на явні очікування, створювати дані через API з унікальними іменами, дати кожному потоку власний драйвер і користувача.
Example. Step 1 is a metric: from CI history I compute each test's flake rate over two weeks and sort by damage. Step 2 is quarantine: tests above a threshold get a tag and run in a separate job that does not block merges, but each has a ticket, an owner and a deadline. Step 3 is at most one retry, used as a signal: a test that passed on the second attempt is shown as flaky in the report, not as green. Step 4 is systemic fixes that remove whole classes of problems: delete Thread.sleep and implicit waits, move to explicit waits, create data through the API with unique names, and give each thread its own driver and user.
Підводні камені. Карантин без дедлайну стає кладовищем, де тести тихо гниють. Масові retry дають гарну статистику і приховують справжні баги. Паралельність, увімкнена до ізоляції даних, сама породжує нові флейки.
Pitfalls. Quarantine without a deadline turns into a graveyard where tests quietly rot. Blanket retries produce nice statistics and hide real bugs. Turning on parallel runs before data is isolated breeds a fresh wave of flakes.
Уточнення. Успіх я міряю не кількістю виправлених тестів, а часткою флейків у загальних прогонах і тим, як часто розробники перезапускають пайплайн вручну.
Follow-up. I measure success not by the number of tests fixed but by the flake share across all runs and by how often developers rerun the pipeline by hand.
// Тест у карантині: окрема джоба, тікет, власник і дедлайн
@Tag("quarantine") // FLAKY-412, власник: команда payments, до 2026-10-15
@Test
void refundAppearsInHistory() {
// Дані створюються через API з унікальним ім'ям, а не кліками в UI
Order order = api.createPaidOrder("user-" + UUID.randomUUID());
historyPage.openFor(order.id());
new WebDriverWait(driver(), Duration.ofSeconds(10))
.until(ExpectedConditions.textToBePresentInElementLocated(
By.cssSelector("[data-testid='order-status']"), "Refunded"));
}
// driver() повертає драйвер поточного потоку з ThreadLocal
// Quarantined test: separate job, ticket, owner and deadline
@Tag("quarantine") // FLAKY-412, owner: payments team, until 2026-10-15
@Test
void refundAppearsInHistory() {
// Data is created via the API with a unique name, not by clicking through the UI
Order order = api.createPaidOrder("user-" + UUID.randomUUID());
historyPage.openFor(order.id());
new WebDriverWait(driver(), Duration.ofSeconds(10))
.until(ExpectedConditions.textToBePresentInElementLocated(
By.cssSelector("[data-testid='order-status']"), "Refunded"));
}
// driver() returns the current thread's driver from a ThreadLocal
@Tag("quarantine") — мітка JUnit 5, за якою збірка виключає тест з основної джоби (у Maven Surefire це excludedGroups) і запускає окремо. UUID.randomUUID() робить дані унікальними для кожного прогону й потоку. textToBePresentInElementLocated чекає, поки в елементі з’явиться потрібний текст, замість фіксованої паузи.@Tag("quarantine") is a JUnit 5 label the build uses to exclude the test from the main job (in Maven Surefire that is excludedGroups) and run it separately. UUID.randomUUID() makes the data unique per run and per thread. textToBePresentInElementLocated waits for the expected text to appear in the element instead of pausing for a fixed time.Що це. Selenium — реалізація стандарту W3C WebDriver з клієнтами для Java, Python, C#, JavaScript і Ruby, Grid для розподілу сесій та підтримкою всіх основних браузерів, включно зі справжнім Safari через safaridriver. Playwright керує браузерами власним протоколом, має автоочікування перед кожною дією, трейсинг, контроль мережі та ізольовані контексти з коробки, але працює зі своїми збірками Chromium, Firefox і WebKit. Selenide — Java-обгортка над Selenium з лаконічним API та вбудованими очікуваннями в should і діях.
What it is. Selenium implements the W3C WebDriver standard, with clients for Java, Python, C#, JavaScript and Ruby, Grid for distributing sessions, and support for every major browser, including real Safari through safaridriver. Playwright drives browsers over its own protocol and ships auto-waiting before each action, tracing, network control and isolated contexts out of the box, but it runs its own builds of Chromium, Firefox and WebKit. Selenide is a Java wrapper over Selenium with a concise API and waits built into should and into actions.
Навіщо. Вибір впливає на стабільність, швидкість написання тестів і на те, хто зможе їх підтримувати. Питання на співбесіді перевіряє, чи ти зважуєш обмеження проєкту, а не повторюєш моду.
Why it matters. The choice affects stability, how fast tests get written and who can maintain them. The interview question checks whether you weigh the project's constraints rather than repeating what is fashionable.
Приклад. Новий вебпродукт, команда на TypeScript, потрібні швидкий фідбек і мок мережі — Playwright. Велика Java-кодова база, тисячі тестів на Selenium, вимога прогону в реальному Safari та на хмарній фермі — лишаюсь на Selenium, а щоб прибрати ручні очікування, можу поступово додати Selenide, бо він працює поверх того ж драйвера. Код нижче показує одну дію в трьох інструментах.
Example. A new web product, a TypeScript team, a need for fast feedback and network mocks: Playwright. A large Java codebase with thousands of Selenium tests, a requirement to run on real Safari and on a cloud device farm: I stay on Selenium, and to get rid of hand-written waits I can bring in Selenide gradually, because it runs on the same driver. The code below shows one action in all three tools.
Підводні камені. WebKit у Playwright — не те саме, що Safari на macOS, і для деяких клієнтів це критично. Міграція тисяч тестів коштує місяців, і нестабільність частіше йде від поганих очікувань, ніж від інструмента. Selenide ховає очікування, але не лагодить погані локатори чи залежні тести.
Pitfalls. Playwright's WebKit is not the same as Safari on macOS, and for some clients that matters a lot. Migrating thousands of tests costs months, and flakiness more often comes from poor waiting than from the tool. Selenide hides waits but does not fix bad locators or interdependent tests.
Уточнення. Мій критерій: мова команди, обов’язкові браузери та інфраструктура, потреба в мережевих моках і трейсингу, вартість переходу. Selenium 4 з BiDi частково скорочує розрив у роботі з мережею та логами.
Follow-up. My criteria are the team's language, the required browsers and infrastructure, the need for network mocks and tracing, and the cost of switching. Selenium 4 with BiDi partly closes the gap on network and log access.
// Selenium: очікування — відповідальність автора тесту
new WebDriverWait(driver, Duration.ofSeconds(10))
.until(ExpectedConditions.elementToBeClickable(By.cssSelector("[data-testid='pay']")))
.click();
// Selenide: той самий WebDriver під капотом, очікування вбудовані
$("[data-testid='pay']").shouldBe(visible, enabled).click();
// Playwright for Java: перевірки actionability перед кожною дією
page.getByTestId("pay").click();
// Selenium: waiting is the test author's job
new WebDriverWait(driver, Duration.ofSeconds(10))
.until(ExpectedConditions.elementToBeClickable(By.cssSelector("[data-testid='pay']")))
.click();
// Selenide: the same WebDriver underneath, waits are built in
$("[data-testid='pay']").shouldBe(visible, enabled).click();
// Playwright for Java: actionability checks before every action
page.getByTestId("pay").click();
until повертає елемент, коли умова виконалась, і на ньому викликається click(). У Selenide $ — статичний метод, що створює лінивий елемент, а shouldBe(visible, enabled) повторює перевірку умов до таймауту з конфігурації. У Playwright getByTestId створює локатор, а click() сам чекає, поки елемент стане видимим, стабільним і доступним.until returns the element once the condition holds, and click() is called on it. In Selenide, $ is a static method that builds a lazy element, and shouldBe(visible, enabled) keeps re-checking the conditions up to the configured timeout. In Playwright, getByTestId builds a locator and click() itself waits until the element is visible, stable and enabled.Що це. Selenium 4 працює лише за W3C-протоколом і прибрав частину старого API. DesiredCapabilities.chrome() замінили класи ChromeOptions, FirefoxOptions та інші. Методи findElementById і подібні стали deprecated у 4.0 і пізніше зникли — лишився findElement(By...). Таймаути приймають Duration: new WebDriverWait(driver, Duration.ofSeconds(10)), implicitlyWait(Duration). Grid 3 hub/node замінив Grid 4 з новими компонентами, і старі ноди до нього не підключаються.
What it is. Selenium 4 speaks only the W3C protocol and removed parts of the old API. DesiredCapabilities.chrome() gave way to classes like ChromeOptions and FirefoxOptions. Methods such as findElementById were deprecated in 4.0 and removed later, leaving findElement(By...). Timeouts take a Duration: new WebDriverWait(driver, Duration.ofSeconds(10)) and implicitlyWait(Duration). Grid 3 hub and node were replaced by Grid 4 with new components, and old nodes cannot register with it.
Навіщо. Selenium 3 давно не підтримується, нові браузери з ним поводяться непередбачувано, а Selenium Manager, відносні локатори, getShadowRoot, newWindow і BiDi є лише в четвертій версії.
Why it matters. Selenium 3 has long been unsupported, new browsers behave unpredictably with it, and Selenium Manager, relative locators, getShadowRoot, newWindow and BiDi exist only in version 4.
Приклад. План на тисячу тестів: 1) інвентаризація — пошук deprecated викликів компілятором і grep по DesiredCapabilities, findElementBy, TimeUnit; 2) спершу оновити бібліотеку, лишивши старий Grid, і виправити компіляцію в одній DriverFactory, а не в кожному тесті; 3) замінити WebDriverManager на вбудований Selenium Manager; 4) підняти Grid 4 поруч зі старим і перевести джоби частинами; 5) порівнювати частку падінь до і після на тих самих тестах. Останні свіжі 4.x релізи вимагають Java 11+, тож перевір і JDK.
Example. A plan for a thousand tests: 1) inventory, using compiler deprecation warnings and a grep for DesiredCapabilities, findElementBy and TimeUnit; 2) upgrade the library first while keeping the old Grid, fixing compilation in one DriverFactory rather than in every test; 3) swap WebDriverManager for the built-in Selenium Manager; 4) run Grid 4 next to the old one and move jobs over in batches; 5) compare failure rates before and after on the same tests. Recent 4.x releases need Java 11 or newer, so check the JDK as well.
Підводні камені. Власні капабіліті без префікса вендора, як-от "build" для хмарних ферм, W3C-сервер відкидає — їх переносять у "vendor:options". Великий «вибуховий» мердж змішує помилки міграції з флейками і ускладнює відкат.
Pitfalls. A W3C server rejects custom capabilities without a vendor prefix, such as "build" for cloud farms, so they move into "vendor:options". One big-bang merge mixes migration errors with existing flakes and makes a rollback painful.
Уточнення. Корисно одночасно прибрати implicit wait, якщо він був, бо поведінка очікувань змінюється разом із драйверами, і міграція — зручний момент почати з чистого аркуша.
Follow-up. It pays to drop implicit waits at the same time if you had them, since wait behaviour shifts along with the drivers, and a migration is a convenient moment to start clean.
// Selenium 3 (більше не компілюється):
// DesiredCapabilities caps = DesiredCapabilities.chrome();
// WebDriver driver = new RemoteWebDriver(new URL(hubUrl), caps);
// driver.findElementById("login").click();
// new WebDriverWait(driver, 10);
// driver.manage().timeouts().implicitlyWait(10, TimeUnit.SECONDS);
// Selenium 4: Options замість DesiredCapabilities, By замість findElementBy*
ChromeOptions options = new ChromeOptions();
options.addArguments("--headless=new");
WebDriver driver = new RemoteWebDriver(new URL(gridUrl), options);
driver.findElement(By.id("login")).click();
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
// Selenium 3 (no longer compiles):
// DesiredCapabilities caps = DesiredCapabilities.chrome();
// WebDriver driver = new RemoteWebDriver(new URL(hubUrl), caps);
// driver.findElementById("login").click();
// new WebDriverWait(driver, 10);
// driver.manage().timeouts().implicitlyWait(10, TimeUnit.SECONDS);
// Selenium 4: Options instead of DesiredCapabilities, By instead of findElementBy*
ChromeOptions options = new ChromeOptions();
options.addArguments("--headless=new");
WebDriver driver = new RemoteWebDriver(new URL(gridUrl), options);
driver.findElement(By.id("login")).click();
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
RemoteWebDriver у Selenium 4 приймає ChromeOptions напряму, бо Options самі є капабіліті. --headless=new вмикає новий headless-режим Chrome. Duration.ofSeconds(10) замінює пару «число плюс TimeUnit».RemoteWebDriver takes ChromeOptions directly, because Options are capabilities themselves. --headless=new turns on Chrome's new headless mode. Duration.ofSeconds(10) replaces the pair of a number and a TimeUnit.