Первая боль начинающего автоматизатора выглядит примерно так.

Написали двадцать 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 и явным поиском через драйвер — это проще, понятнее и предсказуемее. Когда проект вырастет и появится конкретная необходимость в кэшировании элементов или ленивой инициализации, тогда и подключайте.

Селекторы: что выбирать

Порядок предпочтения такой:

  1. data-test или data-testid атрибуты — лучший вариант, специально для тестов, не меняются при редизайне.

  2. id — стабильный, но не всегда есть и иногда используется в JS‑логике, что мешает.

  3. Атрибут name для полей формы — стабильный для legacy‑форм.

  4. CSS‑селектор по уникальной комбинации атрибутов — рабочий вариант, читаемый.

  5. 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-специалистов.

Комментарии (0)