Первая боль начинающего автоматизатора выглядит примерно так.
Написали двадцать UI‑тестов на регистрацию, логин, добавление товара в корзину и оформление заказа. Поставили в CI, все зелёные, тимлид доволен, фидбек о пройденных тестах висит в общем чате. Через неделю фронтенд‑команда выкатывает редизайн формы регистрации — переименовывают поле «Email» в «E‑mail», меняют идентификатор email-input на user-email, переставляют кнопку «Создать аккаунт» в правый верхний угол.
Из двадцати тестов падают шестнадцать. Лезете чинить — а локатор By.id("email-input") встречается в 17 местах: в самих тестах, в каких‑то хелперах, в утильных классах, в одном из тестов даже два раза, потому что когда‑то его скопировали и забыли вынести. Замена идёт через find‑replace, но в одном файле текст немного другой, и его find‑replace пропускает. Тест проходит локально, на CI падает, потому что там кэшированная сборка. Через три часа всё чините, мерджите, на следующий день фронтенд переделывает форму логина.
Page Object Pattern — это паттерн, который ровно эту проблему и решает. Большинство автоматизаторов про неё знают, но реализуют по‑разному, часто криво. Разберём, как сделать Page Object с нуля так, чтобы через полгода работы он не превратился в кашу.
Идея паттерна
Page Object — это класс, который описывает одну страницу или один независимый компонент страницы. Внутри класса лежит три вещи: локаторы элементов, методы для действий пользователя на странице и методы получения состояния. Тесты не знают про устройство DOM, не знают, как именно искать поле email, и не знают, какое именно событие генерируется при клике. Они работают с языком домена: открой страницу логина, заполни форму, нажми «Отправить», проверь, что попал на главную.
Когда вёрстка меняется, чинится один файл — Page Object нужной страницы. Тесты остаются нетронутыми. Это базовое обещание паттерна, и именно его обычно ломают неправильной реализацией.
Структура проекта
Начнём с того, как разложить файлы. Стандартный Maven‑проект на Java:
src/ ├── main/ │ └── java/ │ └── com/example/autotests/ │ ├── pages/ │ │ ├── BasePage.java │ │ ├── LoginPage.java │ │ ├── RegistrationPage.java │ │ ├── AccountPage.java │ │ └── ProductPage.java │ ├── components/ │ │ ├── HeaderComponent.java │ │ ├── FooterComponent.java │ │ └── CartWidget.java │ ├── driver/ │ │ └── DriverFactory.java │ └── config/ │ └── TestConfig.java └── test/ └── java/ └── com/example/autotests/tests/ ├── BaseTest.java ├── LoginTest.java └── RegistrationTest.java
Page Object и компоненты лежат в main, не в test. Это инфраструктура, она будет переиспользоваться, на неё будут ссылаться разные тесты, она достойна того же отношения, что и основной код. В test живут только сами тесты — короткие методы со сценариями.
В pom.xml минимум зависимостей:
<dependencies> <dependency> <groupId>org.seleniumhq.selenium</groupId> <artifactId>selenium-java</artifactId> <version>4.27.0</version> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>5.11.4</version> <scope>test</scope> </dependency> <dependency> <groupId>org.assertj</groupId> <artifactId>assertj-core</artifactId> <version>3.27.0</version> <scope>test</scope> </dependency> </dependencies>
Базовый класс страницы
Без базового класса каждый Page Object начинает дублировать одну и ту же логику: создание WebDriverWait, обёртки над ожиданиями, тривиальные действия типа clear() + sendKeys(). Через пять страниц это превращается в копипасту, а через десять — в копипасту с расходящимся поведением, потому что в одной странице кто‑то починил баг, а в остальных нет.
package com.example.autotests.pages; import org.openqa.selenium.By; import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebElement; import org.openqa.selenium.support.ui.ExpectedConditions; import org.openqa.selenium.support.ui.WebDriverWait; import java.time.Duration; public abstract class BasePage { protected final WebDriver driver; protected final WebDriverWait wait; private static final Duration DEFAULT_WAIT = Duration.ofSeconds(10); protected BasePage(WebDriver driver) { this.driver = driver; this.wait = new WebDriverWait(driver, DEFAULT_WAIT); } protected WebElement waitForVisible(By locator) { return wait.until(ExpectedConditions.visibilityOfElementLocated(locator)); } protected WebElement waitForClickable(By locator) { return wait.until(ExpectedConditions.elementToBeClickable(locator)); } protected void type(By locator, String text) { WebElement element = waitForVisible(locator); element.clear(); element.sendKeys(text); } protected void click(By locator) { waitForClickable(locator).click(); } protected String getText(By locator) { return waitForVisible(locator).getText(); } protected boolean isVisible(By locator) { try { waitForVisible(locator); return true; } catch (Exception e) { return false; } } protected void waitForUrlContains(String fragment) { wait.until(ExpectedConditions.urlContains(fragment)); } }
Этот класс — основа всего. Дальше каждая страница наследуется от него и получает готовый набор примитивов. Если завтра захотите перейти на Selenide или добавить ретраи на StaleElementReferenceException, это делается в одном месте.
Конкретный Page Object
Возьмём страницу логина интернет‑магазина. На ней есть форма с email и паролем, кнопка «Войти», чекбокс «Запомнить меня», ссылка на восстановление пароля и блок с ошибкой, который появляется при неверных кредах.
package com.example.autotests.pages; import org.openqa.selenium.By; import org.openqa.selenium.WebDriver; public class LoginPage extends BasePage { private static final String URL = "https://shop.example.com/login"; private static final By EMAIL_INPUT = By.cssSelector("[data-test='login-email']"); private static final By PASSWORD_INPUT = By.cssSelector("[data-test='login-password']"); private static final By REMEMBER_CHECKBOX = By.cssSelector("[data-test='login-remember']"); private static final By SUBMIT_BUTTON = By.cssSelector("[data-test='login-submit']"); private static final By FORGOT_PASSWORD_LINK = By.cssSelector("[data-test='forgot-password']"); private static final By ERROR_BANNER = By.cssSelector("[data-test='login-error']"); public LoginPage(WebDriver driver) { super(driver); } public LoginPage open() { driver.get(URL); waitForVisible(EMAIL_INPUT); return this; } public LoginPage fillEmail(String email) { type(EMAIL_INPUT, email); return this; } public LoginPage fillPassword(String password) { type(PASSWORD_INPUT, password); return this; } public LoginPage checkRememberMe() { click(REMEMBER_CHECKBOX); return this; } public AccountPage submitWithValidCredentials() { click(SUBMIT_BUTTON); waitForUrlContains("/account"); return new AccountPage(driver); } public LoginPage submitExpectingError() { click(SUBMIT_BUTTON); waitForVisible(ERROR_BANNER); return this; } public ForgotPasswordPage clickForgotPassword() { click(FORGOT_PASSWORD_LINK); return new ForgotPasswordPage(driver); } public String getErrorText() { return getText(ERROR_BANNER); } public boolean isErrorVisible() { return isVisible(ERROR_BANNER); } }
В этом коде заложено несколько важных решений. Первое — методы действий возвращают либо тот же Page Object для цепочки вызовов, либо следующую страницу, если действие меняет URL. Второе — есть два разных метода для нажатия кнопки «Войти»: submitWithValidCredentials ждёт редирект на /account, submitExpectingError ждёт появления баннера с ошибкой. Без такого разделения тест после клика не понимает, чего ждать, и либо падает по таймауту, либо ловит непонятное состояние.
Третье решение — селекторы через data-test атрибуты. На фронтенде это специальные атрибуты, которые добавляются именно для тестов и не используются ни в стилях, ни в логике.
Тест с цепочкой вызовов
Теперь сам тест. Это короткий метод со сценарием:
package com.example.autotests.tests; import com.example.autotests.pages.LoginPage; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; public class LoginTest extends BaseTest { @Test void loginWithInvalidPasswordShowsError() { String errorText = new LoginPage(driver) .open() .fillEmail("user@example.com") .fillPassword("wrong-password") .submitExpectingError() .getErrorText(); assertThat(errorText).isEqualTo("Неверный логин или пароль"); } @Test void loginWithValidCredentialsRedirectsToAccount() { boolean isOnAccountPage = new LoginPage(driver) .open() .fillEmail("valid-user@example.com") .fillPassword("correct-password") .submitWithValidCredentials() .isUserMenuVisible(); assertThat(isOnAccountPage).isTrue(); } }
Тест читается как сценарий из ТЗ. Менеджер по тестированию посмотрит и поймёт, что проверяется. Тестировщик‑новичок откроет файл и за минуту разберётся в логике. Через год, когда вёрстка кардинально поменяется, тесты останутся точно такими же — поменяется только содержимое Page Object.
BaseTest отвечает за инициализацию драйвера, скриншоты при падении и закрытие браузера:
public abstract class BaseTest { protected WebDriver driver; @BeforeEach void setUp() { driver = DriverFactory.create(); } @AfterEach void tearDown(TestInfo testInfo) { if (driver != null) { driver.quit(); } } }
Компоненты для повторяющихся блоков
Шапка с навигацией повторяется на всех страницах. Корзина в правом углу — тоже. Модалка подтверждения возраста при первом заходе — на половине страниц. Если для каждой такой штуки делать отдельные методы в каждом Page Object, это будет копипаста на уровне пятнадцати дублирующихся методов.
Поэтому компоненты выносятся в отдельные классы:
package com.example.autotests.components; import com.example.autotests.pages.BasePage; import org.openqa.selenium.By; import org.openqa.selenium.WebDriver; public class HeaderComponent { private final WebDriver driver; private final BasePage parent; private static final By LOGO = By.cssSelector("[data-test='header-logo']"); private static final By CART_BUTTON = By.cssSelector("[data-test='header-cart']"); private static final By USER_MENU = By.cssSelector("[data-test='header-user-menu']"); private static final By LOGOUT_BUTTON = By.cssSelector("[data-test='header-logout']"); public HeaderComponent(WebDriver driver, BasePage parent) { this.driver = driver; this.parent = parent; } public CartPage openCart() { driver.findElement(CART_BUTTON).click(); return new CartPage(driver); } public boolean isUserLoggedIn() { return !driver.findElements(USER_MENU).isEmpty(); } public LoginPage logout() { driver.findElement(USER_MENU).click(); driver.findElement(LOGOUT_BUTTON).click(); return new LoginPage(driver); } }
Дальше любая страница, у которой есть шапка, экспозит её через геттер:
public class AccountPage extends BasePage { private final HeaderComponent header; public AccountPage(WebDriver driver) { super(driver); this.header = new HeaderComponent(driver, this); } public HeaderComponent header() { return header; } }
И тест получает доступ к шапке через accountPage.header().openCart(). Логика шапки лежит в одном файле, меняется в одном месте.
Когда нужен PageFactory
Selenium предоставляет PageFactory — механизм инициализации полей через аннотацию @FindBy и вызов PageFactory.initElements(driver, this) в конструкторе. Выглядит это так:
public class LoginPageFactory extends BasePage { @FindBy(css = "[data-test='login-email']") private WebElement emailInput; @FindBy(css = "[data-test='login-submit']") private WebElement submitButton; public LoginPageFactory(WebDriver driver) { super(driver); PageFactory.initElements(driver, this); } }
Удобно, ну не очень. PageFactory скрывает момент поиска элемента — он происходит при каждом обращении к полю, и это не всегда то, что нужно. На странице с динамическими элементами это приводит к StaleElementReferenceException в самых неожиданных местах. Плюс PageFactory несовместим с современными @CacheLookup оптимизациями, плюс отладка превращается в чёрный ящик.
На старте проекта PageFactory не нужен. Работайте с By и явным поиском через драйвер — это проще, понятнее и предсказуемее. Когда проект вырастет и появится конкретная необходимость в кэшировании элементов или ленивой инициализации, тогда и подключайте.
Селекторы: что выбирать
Порядок предпочтения такой:
data-testилиdata-testidатрибуты — лучший вариант, специально для тестов, не меняются при редизайне.id— стабильный, но не всегда есть и иногда используется в JS‑логике, что мешает.Атрибут
nameдля полей формы — стабильный для legacy‑форм.CSS‑селектор по уникальной комбинации атрибутов — рабочий вариант, читаемый.
XPath — последний выбор. Хрупкий, медленный, нечитаемый.
XPath имеет смысл только когда нужно искать по тексту (//button[text()='Купить']) или по сложной иерархии, которую CSS не выразит. Во всех остальных случаях CSS лучше.
Где обычно ломаются проекты на Page Object
Распространённый антипаттерн — методы возвращают WebElement наружу. Page Object перестаёт быть абстракцией страницы и превращается в каталог элементов. Тесты начинают сами вызывать element.click() и element.sendKeys(), и весь смысл паттерна теряется. Page Object должен скрывать DOM, а не выставлять его наружу.
Второй частый антипаттерн — assertion внутри Page Object. Метод называется submitAndCheckSuccess, внутри есть assertEquals(driver.getTitle(), "Личный кабинет"). Это смешивает действия с проверками, делает Page Object зависимым от тестового фреймворка и ломает переиспользование между разными типами тестов. Правило простое: Page Object делает действия и возвращает данные. Проверки — в тесте.
Третий — слишком толстые Page Object. На странице двадцать форм, шесть модалок и таблица с фильтрами — и всё это в одном классе на 800 строк. Решается дроблением на компоненты: модалка фильтров — отдельный класс, таблица результатов — отдельный класс, форма поиска — отдельный класс. Сама страница только хранит ссылки на компоненты и предоставляет к ним доступ.
Итого
Page Object — это класс на страницу или независимый компонент, наследник базового класса с общими ожиданиями, селекторы через data-test атрибуты, методы возвращают тот же Page Object или следующую страницу для цепочки вызовов, проверки в тесте, действия в Page Object, повторяющиеся блоки выносятся в отдельные классы компонентов.
Тест становится коротким, читаемым и устойчивым к изменениям вёрстки. При смене дизайна правится один файл вместо двадцати.
Когда ручное тестирование уже упирается в повторяемые проверки, а автотесты хочется писать не «как получится», важно разобраться не только с Selenium, но и с Java, JUnit, архитектурой тестового проекта и поддержкой тестов в реальной разработке.
На курсе OTUS «Автоматизатор тестирования на Java. Базовый уровень» эти темы проходят как единый путь: от базового программирования и ручного тестирования до автоматизации на Java, чтобы перейти в QA‑команду и работать с тестами осознанно, а не копировать готовые шаблоны.
Больше практических разборов по тестированию, Java и другим IT‑направлениям собрали в июньском дайджесте открытых уроков.
А ещё подписывайтесь на канал OTUS в MAX — там собираем полезное для разработчиков, тестировщиков, аналитиков и других IT-специалистов.