В этой статье я хочу поделиться личным опытом эволюции UI-тестов в AQA-проекте. Речь пойдет о том, как из типичных простыней с assertEquals(), множественными прямыми вызовами методов страницы и деталями реализации можно прийти к более выразительному и читаемому подходу - внутреннему DSL поверх Page Object.


DISCLAIMER

Это не академическое исследование и не попытка изобрести велосипед в виде паттерна fluent API. Скорее, это практический разбор того, как в реальном проекте появилась необходимость:

  • отделить действия от проверок

  • убрать технические детали из текстов

  • сделать проверки ближе к языку предметной области

  • повысить читаемость и поддерживаемость тестов

Материал можно воспринимать как небольшой туториал: я последовательно покажу трансформацию обычного UI-теста в DSL-ориентированный вариант и разберу применяемые решения.

Мы начнем с типичного теста в стиле "всё в одном месте", затем выделим слой получения тестовых данных, слой действий и отдельный кастомный assertion-слой, которые будут построены в fluent-стиле. В качестве основы будет использоваться Java 17, Spring и компонентная модель страницы. В конце статьи подведу итог не только о преимуществах подхода, но и о его ограничениях.

Кому будет полезен этот материал

Эта статья ориентирована в первую очередь на QA‑инженеров и автоматизаторов, которые:

  • уже пишут UI‑тесты и начинают ощущать, что проект «расползается»

  • хотят привести существующий тестовый код в порядок

  • понимают, что Page Object есть, но единой архитектурной концепции - нет

  • или только начинают строить автоматизацию на проекте и прогнозируют большое количество похожих UI‑сценариев

Чаще всего ситуация выглядит так: тесты уже работают, Page Object реализован, локаторы вынесены, AssertJ подключён - всё формально по правилам.

Но при этом:

  • тесты становятся длинными и трудно читаемыми

  • Page Object превратился в God Object

  • страница разрастается до сотен методов

  • изменения в UI вызывают каскадные правки

  • тесты дублируют одни и те же сценарии

  • проверки разбросаны по всему проекту

  • нет единого DSL‑подхода

Вроде бы всё правильно, но ощущение хаоса в коде не покидает.

Чего не будет в статье

Это не руководство:

  • по Selenide

  • по Selenium

  • по JUnit

  • по Spring Boot

Мы будем использовать их как инструменты, но фокус статьи - архитектура тестов.

Речь пойдёт не о том, как кликнуть кнопку, а о том, как сделать так, чтобы тесты стали читаемыми и легче поддерживались.

Что такое DSL и fluent API

Для начала небольшая теория вкратце.

DSL (Domain-Specific Language) - это способ описывать поведение системы на языке предметной области, а не на языке технических деталей.

В контексте UI-тестов это означает, что вместо:

assertEquals("Success", page.getSummary().getNotificationText());
assertTrue(page.getSummary().isPriceLabelDisplayed());
assertEquals(expectedPrice, page.getCalculation().getPrice());

мы стремимся писать проверки в терминах домена:

assertThat(page)
              .summary().hasSuccessNotification().and()
              .summary().hasPriceLabel().and()
              .calculation().hasCalculatedPrice();

Такой стиль достигается через fluent API - когда методы возвращают объект для продолжения цепочки вызовов, а код читается как последовательность действий или проверок.

Это внутренний DSL (internal DSL), реализованных средствами самого языка Java.

Классические UI-тесты

Я начинал на проекте с полного нуля. Задача – покрыть автоматизацией критические бизнес-сценарии. Изначально тесты выглядели вполне стандартно:

  • подготовка тестовых данных

  • выполнение действий через методы Page Object

  • набор assert'ов в конце теста

Пока тестов немного, такой подход полностью рабочий, но проблемы начинаются позже. Со временем количество тестов выросло.

Вместе с этим:

  • каждый тест стал занимать 50-100 строк

  • в конце было до 5-10 проверок

  • проверки повторялись от теста к тесту

  • тест знал слишком много о внутренней структуре страницы

  • любой рефакторинг или правка тестов в связи с изменением функционала превращался в долгую работу

Основная проблема была не в простоте кода, а в когнитивной нагрузке. Тесты стало просто тяжело читать физически (хотя я же сам их и пишу). Они стали выглядеть как простыни из однотипных действий и проверок.

Чтобы понять, что именно проверяется с точки зрения бизнеса, нужно было визуально фильтровать технический шум из getText(), isDisplayed(), equals(), shouldBe(), обращений к элементам страницы и так далее.

Изменение становилось все дороже: изменение UI-структуры тянуло за собой массовые правки тестов, потому что проверки жили непосредственно в них.

Почему я не пошёл в сторону Cucumber

Ранее я уже работал с Cucumber и внешним DSL (Gherkin). Логично было бы рассмотреть этот вариант снова.

Однако в текущем проекте были ограничения.

1. Сложный и разрастающийся контекст

Основная сложность, с которой я и мои коллеги столкнулись на предыдущем проекте именно с Cucumber, - при работе со сложными сценариями неизбежно появляется слой контекста:

  • данные передаются между степами

  • состояние хранится в общем объекте

  • появляются зависимости между шагами

Контекст со временем превратился в монстра из тысяч строк кода, а логика размылась по step definitions.

2. Фрагментация сценариев

Чтобы понять один сценарий, нужно:

  • открыть feature-файл

  • найти соответствующие step definitions

  • переходить между методами

  • восстанавливать общую картину

Код перестает быть линейным. Для сложных тестов это особенно усложняет поддержку.

3. Тесты не читают менеджеры

BDD-подход часто оправдывают тем, что feature-файлы понятны бизнесу. В нашем случае этого не происходило: тесты использовались исключительно инженерной командой.

То есть внешний DSL не давал дополнительной ценности в виде "читабельности для бизнеса".

4. Иллюзия простоты

Cucumber Feature-файл выглядит аккуратно, но реальная сложность живет в Java-реализации степов. В большом проекте растет число однотипных степов, похожих друг на друга, но имеющих разную реализацию.

В итоге внешний DSL добавляет тяжело поддерживаемый слой абстракции, но не решает проблему инженерной читаемости внутри команды. Приходится буквально запоминать, что конкретно делает каждый степ внутри. Кроме того, степы обрастают параметрами, превращаясь в длинные предложения.

Почему простыню тоже нельзя было оставить

Возвращаться к чистым JUnit-тестам с набором assert'ов тоже не хотелось.

С ростом тестового набора стало очевидно:

  • проверки повторяются

  • тесты становятся громоздкими

  • бизнес-смысл растворяется в технических деталях

  • поддержка требует все больше времени

Мне нужен был способ:

  • явно отделить действия от проверок

  • вынести бизнес-проверки в отдельный слой

  • сократить объем технического кода в тестах

  • повысить читаемость без перехода на внешний DSL

Решение: внутренний DSL поверх Page Object

Вместо внешнего DSL было решено изменить структуру теста, используя Java.

Идея простая:

  • оставить существ��ющую Page Object структуру

  • разделить страницу на компоненты

  • сгруппировать действия по шагам

  • добавить assertion-слой поверх компонентов

  • реализовать fluent API, чтобы тест читался как сценарий

  • тест не должен содержать getText() и isDisplayed(), а также прочие технические детали

Далее расскажу, как привести классический UI-тест к лаконичному читаемому коду с использованием подходов, описанных выше.

Реализуем DSL

Дано:

  • Java, Selenide, Spring Boot 3.x

  • классический UI-тест с Page Object

  • объект страницы имеет все необходимые локаторы элементов и описанные действия с ними

  • проверки реализованы через AssertJ (либо, через нативные проверки Selenide)

  • набор тестовых данных получается в тесте через билдер

Начнём с того, что фиксируем отправную точку - приведенный ниже тест является вымышленным и служит исключительно для демонстрации. В нём собраны все типичные боли простыни из цепочек вызовов и AssertJ-проверок. Так как наша компания осуществляет свою деятельность в сфере страхования, то придумаем следующий кейс: тест открывает страницу страхования, вбивает данные в формы контракта, страхователя и застрахованного транспортного средства, выполняет расчет и оформление. Сценарий линейный: логин, открытие страницы, ручное заполнение десятков полей и такие же ручные проверки. На странице также есть блок калькуляции, блок оплаты и блок саммари, куда выводится итоговая информация в кратком виде.

public class ContractSubmissionTest {

    @Test
    @DisplayName("Тест: оформление договора")
    public void testContractSubmission() {

        Page page = new Page();

        // получаем набор тестовых данных
        TestData data = TestDataBuilder.getDefault().build();

        authService.login();
        page.openPage();

        // заполняем форму
        page
            .selectInsuranceCompany(data.getInsuranceCompany())
            .enterInsurancePolicyNumber(data.getInsurancePolicyNumber())
            .enterInsuranceContractStartDate(data.getInsuranceStartDate())
            .enterInsuranceContractEndDate(data.getInsuranceEndDate())
            .enterLastName(data.getLastname())
            .enterFirstName(data.getFirstname())
            .enterMiddleName(data.getMiddlename())
            // + остальные поля для заполнения формы страхователя

            .selectVehicleBrandFromTheDropdown(data.getBrand())
            .selectVehicleModelFromTheDropdown(data.getModel())
            .enterVehicleYearOfProduction(data.getYearOfProduction())
            .enterVehiclePrice(data.getPrice())
            // + остальные поля для заполнения параметров автомобиля

        // действия на странице - кликаем на кнопки расчета и оформления
        page.clickCalculateButton();
        Assertions.assertThat(page.isSuccessCalculationNotificationDisplayed())
                .as("Нотификация об успешном расчете должна быть отображена")
                .isTrue();
        Assertions.assertThat(page.getContractStatusLabel().getText())
                .as("Договор должен быть переведен в статус Проект")
                .isEqualTo(ContractStatus.PROJECT.getText());

        page.clickSubmitButton();
        // проверяем, что договор оформился, есть сумма, статус и блок оплаты
        Assertions.assertThat(page.isSuccessSubmissionNotificationDisplayed())
                .as("Нотификация об успешном оформлении должна быть отображена")
                .isTrue();
        Assertions.assertThat(page.getContractStatusLabel().getText())
                .as("Договор должен быть переведен в статус Оформленный")
                .isEqualTo(ContractStatus.ISSUED.getText());
        Assertions.assertThat(page.getPriceLabel().getText())
                .as("Сумма страховой премии должна совпадать с ожидаемой")
                .isEqualTo(data.getInsurancePrice());
        Assertions.assertThat(page.isPaymentHeaderDisplayed())
                .as("Блок оплаты должен быть отображен на странице")
                .isTrue();
        Assertions.assertThat(page.getInsuranceProgram().getText())
                .as("Программа страхования должна совпадать с ожидаемой")
                .isEqualTo(data.getInsuranceProgram());

    }
}

Такую простыню трудно сопровождать: поля раскиданы в одном методе, логика проверки дублируется, а читать тест как историю невозможно. На этом этапе я зафиксировал симптомы, которые хочу вылечить: вся страница живёт в тесте, поле - значит метод, свитч - значит ещё один метод, и так далее.

Шаг 1. Декомпозиция Page Object: от монолита к компонентной модели

Дальше начинаем раскладывать страницу. Ключевая идея - вместо одного класса Page собрать полноценную объектную модель с компонентами, которые соответствуют визуальным блокам UI. Именно с этого шага тест превращается в DSL: мы говорим не «введи текст в поле», а «заполни секцию договора».

Исходная версия теста выглядит примерно так:

  • создаётся экземпляр страницы,

  • последовательно вызываются десятки методов enterThis() и selectThat(),

  • затем кликаются кнопки,

  • после чего идёт набор Assertions.assertThat(...)

Даже если методы страницы возвращают this, формально это fluent‑цепочка, но по сути тест остаётся перегруженным деталями DOM, жёстко связанным со структурой страницы и сложным для сопровождения.

Прежде чем говорить о DSL, нужно решить архитектурную проблему - монолитный Page Object. Проблема, которая уже назревает при таком подходе, - God Object в UI-тестах. В исходной модели вся страница представлялась одним классом Page, который содержал десятки локаторов, десятки методов ввода, методы кликов, методы получения текстов, методы проверок.

Со временем такой класс начинает:

  • разрастаться до сотен методов

  • нарушать принцип единственной ответственности

  • становиться источником конфликтов при параллельной разработке

  • превращаться в чёрную дыру для любой новой логики

Фактически мы получаем UI‑аналог God Object.

Но если посмотреть на страницу глазами пользователя, она не является монолитом. Она состоит из логических блоков:

  • Договор

  • Страхователь

  • Транспортное средство

  • Калькуляция

  • Оплата

  • Итог

Значит, и модель страницы должна отражать эту структуру.

Шаг 2. Разделяем страницу на компоненты

Вместо одного большого Page Object мы вводим компонентную модель.

Чтобы тесты перестали держаться на огромном Page Object с сотней методов, делим страницу на осмысленные компоненты. Логика разбивки такая: каждый визуальный блок, который пользователь воспринимает как цельный кусок интерфейса (договор, страхователи, автомобиль, сводка и т.д.), получает собственный объект.

В нашем случае, когда тест заполняет данные страховки, он фактически работает с секцией договора. По этой же аналогии, появляются кандидаты на компоненты:

  • ContractSectionComponent - всё, что относится к договору страхования

  • InsurantSectionComponent - данные страхователя

  • VehicleSectionComponent - автомобиль

  • SummaryComponent - верхняя сводка с кнопками «Рассчитать», «Оформить»

  • PaymentSectionComponent, CalculationSectionComponent - блоки результата

В исходной простыне каждый из этих блоков был просто набором методов page.enterSomething(). На этом шаге фиксируем, какие поля и кнопки к какому блоку относятся, и рисуем себе карту страницы.

Шаг 3. Изоляция DOM внутри компонента

Дальше у каждого блока появляется собственный класс. Рассмотрим ContractSectionComponent:

@Slf4j
@Getter
@Component
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public class ContractSectionComponent implements Fillable {
    private final SelenideElement componentContainer = $x("//div[@data-test-id='contract-section']");
    // … ключевые элементы контейнера ...
}

Здесь стоит дать несколько пояснений:

1. Spring-скоуп - почему SCOPE_PROTOTYPE? Потому что каждый тест должен получать свежий экземпляр страницы, и состояние не должно утекать между тестами.

2. Для чего нужен элемент контейнера? Это верхний XPath, ограничивающий область поиска, чтобы локаторы жили внутри блока. Вместо того чтобы хранить все XPath’ы на странице в одном классе, каждый компонент знает только о своём DOM:

private final SelenideElement contractStartDate = componentContainer.$x(".//input[@data-test-id='contract-StartDate-input']");

Это решение даёт пару важных преимуществ:

  • Защита от конфликтов. Если на странице появится другой инпут с таким же data-test-id, он не повлияет на текущий компонент.

  • Чёткие границы ответственности. Компонент физически не может взаимодействовать с элементами вне своей зоны.

3. Интерфейс Fillable - единый контракт, обязывающий заполнить секцию. Он позволяет тесту вызывать component.fill(data), не задумываясь о внутренностях. Его будут реализовывать те компоненты, в которых будут поля для заполнения.

public interface Fillable {
    void fill(TestData data);
}

Если есть желание углубить и структуру тестовых данных тоже, разбив их на данные для каждого компонента по раздельности, то для интерфейса можно добавить дженерик, передавая данные только того компонента, который вы собираетесь заполнять:

public interface Fillable<T> {
    void fill(T data);
}

Но это необязательно и зависит только от того, насколько большой набор тестовых данных вы используете (и от вашего желания вкладывать усилия в поддержку этой структуры). Для простоты примера будем использовать обычный вариант.

Шаг 4. Инкапсуляция действий внутри компонента

Следующее преобразование - мы перестаём вызывать методы ввода из теста напрямую.

Переносим в компонент всё поведение, связанное с его данными: заполнение данных (реализуя интерфейс Fillable), выбор, валидацию, чтение. Здесь проявляется fluent API:

@Override
public void fill(TestData data) {
    selectInsuranceCompany(data.getInsuranceCompany());
    enterInsurancePolicyNumber(data.getPolicyNumber());
    enterInsuranceContractStartDate(data.getStartDate());
    enterInsuranceContractEndDate(data.getEndDate());
}

public ContractSectionComponent selectInsuranceCompany(String value) {
    clickInsuranceCompanyDropdown();
    selectOptionByValue(value);
    return this;
}

По аналогии делаем со всеми остальными компонентами.

Что изменилось концептуально? Теперь вместо того чтобы вводить кучу полей, мы говорим в тесте: заполни секцию договора. Таким образом, мы подняли уровень абстракции.

Шаг 5. Заполнение на уровне страницы

Теперь страница становится агрегатором компонентов:

@Slf4j
@Getter
@Component
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public class Page extends BasePage {

    private final ContractSectionComponent contractSectionComponent;
    private final InsurantSectionComponent insurantSectionComponent;
    private final CalculationSectionComponent calculationSectionComponent;
    private final SummaryComponent summaryComponent;
    private final PaymentSectionComponent paymentSectionComponent;

    public Page(ContractSectionComponent contractSectionComponent,
                   InsurantSectionComponent insurantSectionComponent,
                   CalculationSectionComponent calculationSectionComponent,
                   SummaryComponent summaryComponent,
                   PaymentSectionComponent paymentSectionComponent) {
        this.contractSectionComponent = contractSectionComponent;
        this.insurantSectionComponent = insurantSectionComponent;
        this.calculationSectionComponent = calculationSectionComponent;
        this.summaryComponent = summaryComponent;
        this.paymentSectionComponent = paymentSectionComponent;
    }

    public void fillData(TestData data) {
        contractSectionComponent.fill(data);
        insurantSectionComponent.fill(data);
        vehicleSectionComponent.fill(data);
    }
}

Ключевая идея в том, что страница больше не знает деталей DOM каждого компонента - она знает только, какие компоненты у неё есть.

Декомпозируя страницу на компоненты, мы решаем сразу несколько проблем монолита: получаем изоляцию ответственности, независимость изменений, можем использовать компонент повторно. Это важный момент, потому что тест больше не знает порядок заполнения, порядок контролируется страницей, а сама страница становится агрегатором.

Теперь если меняется, например, блок транспортного средства - мы изменяем только VehicleSectionComponent. Ни сам тест, ни остальные компоненты при этом не затрагиваются.

Почему здесь тоже SCOPE_PROTOTYPE? Потому что каждый тест должен получать свежий экземпляр страницы, и состояние не должно утекать между тестами.

Spring теперь управляет созданием компонентов, сам внедряет зависимости и упрощает масштабирование.

А что изменилось теперь в тестах?

В тестах мы больше не создаем новый Page вручную. Вместо этого Spring подсовывает Page, а перед этим мы сразу запоминаем компоненты, которые могут использоваться в тесте напрямую. Добавляем компоненты в поля тестового класса и инициализируем их сразу перед каждым тестом:

@SpringBootTest
public class ContractSubmissionTest {
@Autowired
Page page;

    private ContractSectionComponent contract;
    private VehicleSectionComponent vehicle;
    private InsurantSectionComponent insurant;
    private SummarySectionComponent summary;
    private PaymentSectionComponent payment;
    private CalculationSectionComponent calculation;

    @BeforeEach
    void initComponents() {
        contract = page.getContractSectionComponent();
        vehicle = page.getVehicleSectionComponent();
        insurant = page.getInsurantSectionComponent();
        summary = page.getSummarySectionComponent();
        payment = page.getPaymentSectionComponent();
        calculation = page.getCalculationSectionComponent();
    }

}

А сам тест теперь выглядит так:

@Test
@DisplayName("Тест: оформление договора")
public void testContractSubmission() {
        TestData data = TestDataBuilder.getDefault().build();

        authService.login();
        page.openPage();
        page.fillData(data);

        summary.clickCalculateButton();

        Assertions.assertThat(page.isSuccessCalculationNotificationDisplayed())
                .as("Нотификация об успешном расчете должна быть отображена")
                .isTrue();
        Assertions.assertThat(page.getContractStatusLabel().getText())
                .as("Договор должен быть переведен в статус Проект")
                .isEqualTo(ContractStatus.PROJECT.getText());

        summary.clickSubmitButton();

        Assertions.assertThat(page.isSuccessSubmissionNotificationDisplayed())
                .as("Нотификация об успешном оформлении должна быть отображена")
                .isTrue();
        Assertions.assertThat(page.getContractStatusLabel().getText())
                .as("Договор должен быть переведен в статус Оформленный")
                .isEqualTo(ContractStatus.ISSUED.getText());
        Assertions.assertThat(page.getPriceLabel().getText())
                .as("Сумма страховой премии должна совпадать с ожидаемой")
                .isEqualTo(data.getInsurancePrice());
        Assertions.assertThat(page.isPaymentHeaderDisplayed())
                .as("Блок оплаты должен быть отображен на странице")
                .isTrue();
        Assertions.assertThat(page.getInsuranceProgram().getText())
                .as("Программа страхования должна совпадать с ожидаемой")
                .isEqualTo(data.getInsuranceProgram());
     }

Уже на этом этапе тест стал значительно короче, просто потому, что вся логика действий на странице теперь инкапсулирована внутрь компонентов.

Шаг 6. Введение слоя Steps: переход от Page Object к DSL

На предыдущем этапе мы уже разбили монолитную страницу на компоненты и научили тесты разговаривать с ними через лаконичный API. После декомпозиции тест стал чище, но всё ещё остается техническим.

Даже если мы уже избавились от сотен enterSomething(), тест по‑прежнему напрямую управляет страницей:

page.openPage();
page.fillData(testData);
page.getSummaryComponent().clickSubmitButton();

Это уже выглядит аккуратно и красиво, но с точки зрения DSL мы ещё не достигли нужного уровня абстракции.

Тест всё ещё знает:

  • что страница должна вызвать openPage()

  • что страница должна вызвать fillData()

  • что нужно сделать ряд каких-то действий, чтобы произвести расчёт или оформить договор

Такие цепочки повторяются в десятках сценариев и заставляют тесты знать детали UI. Мы же стремимся к тому, чтобы описывать сценарий словами доменной области: «пользователь вошёл, открыл страницу, заполнил данные, посчитал». Чтобы тест начал описывать сценарий, а не UI‑взаимодействие, нужен ещё один промежуточный слой - TestSteps.

Зачем вообще нужен Steps-слой? Многие считают, что достаточно оперировать действиями той страницы или компонента, с которой мы работаем в данный момент. И в целом это так. Но на практике в сложных сценариях это приводит к длинным повторяющимся цепочкам одних и тех же действий. Мы сократили объем кода, но не решили проблему в корне.

На этом этапе Steps-слой как раз решает проблему читаемости сценария.

Ответственности разделены следующим образом:

Уровень           

Отвечает за

Компонент

Работа с конкретным DOM-блоком

Страница           

Агрегация компонентов

Степ

Бизнес-сценарии

Тест                    

Описание поведения

Чтобы осуществить переход к степам, нам нужно вынести сценарные действия из теста.

Было:

authService.login();
page.openPage();
page.fillData(testData);
page.getSummaryComponent().clickCalculateButton();

Стало:

steps
    .login()
    .openPage()
    .fillData(testData)
    .calculate();

Разница кажется косметической, но архитектурно происходит важное изменение: тест больше не управляет страницей напрямую, а работает через сценарный API.

Для реализации этого механизма создадим класс TestSteps:

@Component
@RequiredArgsConstructor
public class TestSteps {

private final Page page;
private final AuthService authService;

private ContractSectionComponent contract;
private VehicleSectionComponent vehicle;
private SummarySectionComponent summary;
private InsurantSectionComponent insurant;
private CalculationSectionComponent calculation;
private PaymentSectionComponent payment;

@PostConstruct
private void initializeComponents() {
     this.contract = page.getContractSectionComponent();
     this.vehicle = page.getVehicleSectionComponent();
     this.insurant = page.getInsurantSectionComponent();
     this.summary = page.getSummarySectionComponent();
     this.calculation = page.getCalculationSectionComponent();
     this.payment = page.getPaymentSectionComponent();
}

Почему Steps - это Spring-компонент?

Потому что:

  • он зависит от страницы

  • зависит от сервисов (авторизация, работа с файлами и так далее)

  • участвует в управлении состоянием теста

Зачем шаги инициализируют компоненты в @PostConstruct?

Когда я внедрял слой шагов, первая идея была очевидной: просто сохранить внутри TestSteps ссылки на используемые компоненты страницы - сводку (SummarySectionComponent), блок расчёта (CalculationSectionComponent) и так далее. На практике это обернулось проблемой: если пытаться инициализировать эти поля сразу при создании бина, тест получал null.

Что происходит под капотом? Класс Page объявлен со скоупом prototype. Каждый запрос к нему должен возвращать новый экземпляр страницы с чистыми компонентами.

TestSteps - обычный singleton Spring-бин. Он создаётся один раз на весь тестовый контекст.

Когда Spring создаёт TestSteps, поле page уже внедрено, но это ещё не «живая» страница - всего лишь прокси, который выдаст свежий экземпляр при первом обращении. Если попытаться обратиться к компоненту в конструкторе или при объявлении поля (private final SummaryComponent summary = page.getSummaryComponent()), мы обращаемся к page, у которого в этот момент нет инициализированных зависимостей, и получаем null.

Почему @PostConstruct помогает?

@PostConstruct запускается после того, как Spring внедрил все зависимости и закончил создание бина, но до того, как он будет использован в тесте. К этому моменту:

  1. page уже заменён живой прокси.

  2. Все её внутренние зависимости (компоненты) проинициализированы.

  3. Вызов page.getSummaryComponent() возвращает рабочий компонент, а не null.

Поэтому кэширование компонентов безопасно. Теперь шаги оперируют готовыми компонентами, а тесты не делают лишних вызовов page.getSummaryComponent() в каждом методе.

Fluent API внутри Steps

Теперь создадим степы внутри класса, каждый метод будет возвращать this.

@Step("Login to page")
public TestSteps login() {
    authService.login();
    return this;
}

@Step("Open page")
public TestSteps openPage() {
    page.openPage();
    return this;
}

@Step("Fill contract with test data")
public TestSteps fillData(TestData testData) {
    page.fillData(testData);
    return this;
}

Это позволяет строить цепочку:

steps
    .login()
    .openPage()
    .fillData(testData);

Рассмотрим метод calculate():

@Step("Calculate contract")
public TestSteps calculate() {
    summary.clickCalculateButton();
    Assertions.assertThat(summary.isSuccessCalculationNotificationDisplayed())
            .as("Нотификация об успешном расчете должна быть отображена")
            .isTrue();
    Assertions.assertThat(summary.getContractStatusLabel().getText())
            .as("Договор должен быть переведен в статус Проект")
            .isEqualTo(ContractStatus.PROJECT.getText());
    return this;
}

Что здесь важно: степ не просто кликает кнопку, а инкапсулирует ожидаемое поведение и превращает UI-действие в атомарный бизнес-шаг. Теперь calculate() - это не клик, а завершённая бизнес-операция.

Рассмотрим другой пример:

@Step("Select insurance program")
public TestSteps selectInsuranceProgram(String programName) {
    calculation
        .clickSelectProgramTabButton()
        .selectInsuranceProgram(programName);

    Assertions.assertThat(calculation().getInsuranceProgramNameLabel().getText())
            .as("Страховая программа должена быть выбрана")
            .isEqualTo(programName);
    return this;
}

Тесту больше не нужно:

  • знать, какую вкладку открыть

  • знать, где находится дропдаун с названиями страховых программ

  • проверять, что программа выбрана

Он просто говорит: "Выбери страховую программу".

По аналогии с расчетом сделаем также шаг оформления договора:

@Step("Submit contract")
public TestSteps submit() {
    summary.clickSubmitButton();

        Assertions.assertThat(page.isSuccessSubmissionNotificationDisplayed())
                .as("Нотификация об успешном оформлении должна быть отображена")
                .isTrue();
        Assertions.assertThat(page.getContractStatusLabel().getText())
                .as("Договор должен быть переведен в статус Оформленный")
                .isEqualTo(ContractStatus.ISSUED.getText());
    return this;
}

Нужно запомнить принцип: один метод TestSteps = один логический шаг. Steps - это не прокси к Page.  Очень важно соблюдать правило: степ не должен быть равен одному клику, он должен быть равен одному бизнес‑действию.

Например:

  • calculate()

  • submit()

  • searchContract()

  • repeatCalculation()

Но не:

  • clickButton()

  • enterValue()

  • openDropdown()

Как меняется тест после введения Steps?

Он начинает читаться как сценарий:

  1. Авторизоваться

  2. Открыть страницу

  3. Заполнить данные

  4. Выполнить расчёт

  5. Оформить договор

  6. Проверить результат

Последнее, что нам нужно не забыть сделать – это подключить Spring бин со степами в наш тестовый класс:

@Autowired
TestSteps steps;

Почему Steps - это переход к DSL

DSL - это про не синтаксис, это про уровень абстракции.

Когда тест начинает описывать, что происходит, а не какие методы вызываются, то мы приближаемся к декларативному стилю.

Итак, обновим наш тест после добавления степов:

@Test
@DisplayName("Тест: оформление договора")
public void testContractSubmission() {
        TestData data = TestDataBuilder.getDefault().build();

        steps
              .login()
              .openPage()
              .fillData(testData)
              .calculate()
              .submit();

        Assertions.assertThat(page.getSummaryComponent().getPriceLabel().getText())
                .as("Сумма страховой премии должна совпадать с ожидаемой")
                .isEqualTo(data.getInsurancePrice());
        Assertions.assertThat(page.getPaymentComponent().isPaymentHeaderDisplayed())
                .as("Блок оплаты должен быть отображен на странице")
                .isTrue();
        Assertions.assertThat(page.getCalculationComponent().getInsuranceProgram().getText())
                .as("Программа страхования должна совпадать с ожидаемой")
                .isEqualTo(data.getInsuranceProgram());
}

Что мы получили на данном этапе после введения компонентов и степов:    

  • Тест перестал зависеть от структуры страницы

  • Страница перестала быть одним гигантским монолитом с кучей методов

  • Сценарная логика вынесена из теста и разделена по зонам ответственности

  • Локаторы изолированы внутри контейнеров

  • UI-действия объединены в шаги

  • Появилась fluent‑цепочка бизнес‑уровня

  • Тест стал ближе к предметной области

Но тест всё ещё содержит проверки напрямую через AssertJ.

Следующий этап - создание кастомного assertion‑слоя, который инкапсулирует проверки и позволит писать структуры вроде assertThat(page).summary().shows..., тем самым завершив формирование fluent API.

Шаг 7. Создание кастомного assertion‑слоя: от AssertJ к DSL

После введения компонентной модели и слоя Steps тест стал значительно чище.

Но одна проблема осталась - это проверки.

Исходный вариант выглядел так:

Assertions.assertThat(page.getSummaryComponent().getPriceLabel().getText())
        .as("Сумма страховой премии должна совпадать с ожидаемой")
        .isEqualTo(data.getInsurancePrice());
Assertions.assertThat(page.getPaymentComponent().isPaymentHeaderDisplayed())
        .as("Блок оплаты должен быть отображен на странице")
        .isTrue();
Assertions.assertThat(page.getCalculationComponent().getInsuranceProgram().getText())
        .as("Программа страхования должна совпадать с ожидаемой")
        .isEqualTo(data.getInsuranceProgram());

Даже если локаторы скрыты внутри компонентов, тест всё ещё:

  • знает структуру страницы

  • знает, какие элементы нужно проверить

  • знает, что getPriceLabel() возвращает элемент

  • знает, что нужно вызвать.getText()

  • знает формат сравнения

  • работает на уровне технических деталей

  • смешивает сценарий и низкоуровневые проверки

Наша цель - превратить проверки в fluent API.

Этап 1. Компонентные assertion-классы

Подход к преобразован��ю остается прежним. Нам нужно декомпозировать проверки по компонентам, к которым они относятся.

Сначала вынесем конкретные проверки в классы, которые знают только о своём компоненте. Они получают:

  1. Ссылку на компонент (инициализируется через страницу).

  2. Вспомогательный класс PageAssert (чтобы возвращаться в цепочку, об этом дальше).

Разберем на примере компонента сводки, где лежит проверка страховки. Создадим класс с проверками отдельно для этого компонента:

@Slf4j
public class SummarySectionComponentAssert {

    private final SummarySectionComponent summaryComponent;
    private final PageAssert page;

    public SummarySectionComponentAssert(SummarySectionComponent summaryComponent, PageAssert page) {
        this.summaryComponent = summaryComponent;
        this.page = page;
    }

    public SummarySectionComponentAssert showsSuccessfulSubmissionNotification() {
    Assertions.assertThat(summaryComponent.isSuccessSubmissionNotificationDisplayed())
                .as("Нотификация об успешном оформлении должна быть отображена")
                .isTrue();
    return this;
    }

    public SummarySectionComponentAssert showsExpectedInsurancePrice(String price) {
    Assertions.assertThat(summaryComponent.getPriceLabel().getText())
              .as("Сумма страховой премии должна совпадать с ожидаемой")
              .isEqualTo(price);
    return this;
    }
}

Для склеивания проверок между компонентами нам нужен метод, возвращающий нам ссылку на страницу.

Добавим метод and():

public PageAssert and() {
    return page;
}

Это важное связующее звено, которое фактически и строит DSL-контекст между разными компонентами. Вызывая and() мы получаем ссылку на класс проверки того компонента, который собираемся вызвать дальше по цепочке. Это может быть либо тот же компонент, либо какой-то другой.

Создаем аналогичные классы для остальных компонентов.

Компонент оплаты:

public class PaymentSectionComponentAssert {

    private final PaymentSectionComponent paymentComponent;
    private final PageAssert page;

    public PaymentSectionComponentAssert(PaymentSectionComponent paymentComponent, PageAssert page) {
        this.paymentComponent = paymentComponent;
        this.page = page;
    }

    public PageAssert and() {
        return page;
    }

    public PaymentSectionComponentAssert showsHeader() {
        Assertions.assertThat(paymentComponent.isPaymentHeaderDisplayed())
              .as("Блок оплаты должен быть отображен на странице")
              .isTrue();
        return this;
    }

	// … дополнительные проверки для платежного блока …

}

И компонент расчета:

public class CalculationSectionComponentAssert {

    private final CalculationSectionComponent calculationComponent;
    private final PageAssert page;

    public CalculationSectionComponentAssert(CalculationSectionComponent calculationComponent, PageAssert page) {
        this.calculationComponent = calculationComponent;
        this.page = page;
    }

    public PageAssert and() {
        return page;
    }

    public CalculationComponentAssert matchesInsuranceProgramName(String programName) {
        Assertions.assertThat(page.getCalculationComponent().getInsuranceProgram().getText())
              .as("Программа страхования должна совпадать с ожидаемой")
              .isEqualTo(data.getInsuranceProgram());
        return this;
    }

	// … дополнительные проверки для блока расчета …
}

Каждый метод концентрирует:

  • поиск элемента (через компонент)

  • проверку состояния с конкретной формулировкой

Этап 2. Агрегатор PageAssert

Теперь нужен класс, который знает о странице и выдаёт assertion-классы для конкретных секций. Он хранит ссылку на Page, а методами типа summary() или payment() создаёт соответствующие ассерты.

@Slf4j
public class PageAssert {

    private final Page page;

    public PageAssert(Page page) {
        this.page = page;
    }

    public PageAssert and() {
        return this;
    }

    public SummarySectionComponentAssert summary() {
        return new SummarySectionComponentAssert(page.getSummarySectionComponent(), this);
    }

    public PaymentSectionComponentAssert payment() {
        return new PaymentSectionComponentAssert(page.getPaymentSectionComponent(), this);
    }

    public CalculationSectionComponentAssert calculation() {
        return new CalculationSectionComponentAssert(page.getCalculationSectionComponent(), this);
    }

    // при необходимости - другие компоненты, общие проверки (UUID и т.д.)

}

Методы создают специализированные assertion-классы и передают им ссылку на себя - так можно вернуться наверх после проверки.

Здесь происходит важное изменение: проверки теперь группируются по компонентам. Тест больше не спрашивает у страницы: покажи текст, покажи статус, покажи цену. Вместо этого он говорит: перейди к проверкам summary, перейди к проверкам payment.

Этап 3. Единая точка входа CustomAssertions

Чтобы тесты не создавали PageAssert напрямую и говорили человекочитаемо, добавляем класс кастомных проверок, как единую точку входа:

public final class CustomAssertions {
    private CustomAssertions() {
}

    public static PageAssert assertThat(Page page) {
        return new PageAssert(page);
    }
}

Теперь вместо Assertions.assertThat(...) тест пишет assertThat(page) и попадает в наш DSL.

Это финальный шаг к DSL - мы больше не используем напрямую Assertions.assertThat() из AssertJ, а создаём собственный assertion‑контекст.

Этап 4. Сочетание Selenide‑проверок и AssertJ

В своих примерах я специально использовал проверки AssertJ для наглядности и простоты, но никто не запрещает, например, использовать нативные проверки Selenide внутри assertion-слоя или комбинировать их.

Например, в assertion‑слое можно использовать:

  • Selenide‑conditions (shouldBe, shouldHave)

  • AssertJ для бизнес‑логики сравнения

summaryComponent.getContractStatusLabel()
        .shouldBe(visible)
        .shouldHave(text(status.getUiText()));

Таким образом:

  • UI‑ожидания инкапсулируются

  • тайминги централизуются

  • сообщения об ошибках формируются внутри DSL

Этап 5. Заменяем старые проверки на DSL

Вот что у нас было:

Assertions.assertThat(page.getSummaryComponent().getPriceLabel().getText())
        .as("Сумма страховой премии должна совпадать с ожидаемой")
        .isEqualTo(data.getInsurancePrice());
Assertions.assertThat(page.getPaymentComponent().isPaymentHeaderDisplayed())
        .as("Блок оплаты должен быть отображен на странице")
        .isTrue();
Assertions.assertThat(page.getCalculationComponent().getInsuranceProgram().getText())
        .as("Программа страхования должна совпадать с ожидаемой")
        .isEqualTo(data.getInsuranceProgram());

А стало вот так:

assertThat(page)
        .summary().matchesInsurancePriceValue(data.getInsurancePrice()).and()
        .payment().showsHeader().and()
        .calculation().matchesInsuranceProgramName(data.getInsuranceProgram());

Что изменилось?

Тест больше не знает, откуда берется цена. Тест не знает, где лежит label. Тест не знает, в каком формате сравниваются значения.

Эта логика теперь инкапсулирована внутри слоя проверок.

Теперь тест просто пишет:

.summary().matchesInsurancePriceValue(...)

На этом моменте у вас наверняка возник вопрос: допустим, нам нужно вызвать последовательно сразу несколько проверок одного компонента, но мы же уже возвращаем на него ссылку в каждой проверке, разве не лучше просто тогда вызывать их как есть последовательно минуя and()?

Все верно, и здесь исключительно вопрос ваших предпочтений и читаемости. Конечно, вместо того, чтобы писать всю цепочку целиком, например:

assertThat(page)
        .calculation().matchesProgramName(program).and()
        .calculation().showsPaymentSchedule().and()
        .calculation().showsPayment().and()
        .calculation().showsClickableDownloadContractTemplateButton().and()
        .calculation().showsClickableChangeProgramButton().and()
        .summary().matchesInsuranceProgramName(program).and()
        .summary().showsInsurancePriceValue();

мы можем просто последовательно вызывать проверки, обращаясь к каждому отдельному компоненту только один раз:

assertThat(page)
        .calculation()
            .matchesProgramName(program)
            .showsPaymentSchedule()
            .showsPayment()
            .showsClickableDownloadContractTemplateButton()
            .showsClickableChangeProgramButton().and()
        .summary()
            .matchesInsuranceProgramName(program)
            .showsInsurancePriceValue();

Но на мой взгляд второй вариант менее предпочтительный. Вызывая and() только на переходах между компонентами, легко запутаться и забыть добавить его, он теряется визуально в длинной цепочке проверок и логика DSL-контекста кажется менее прозрачной. Поэтому для себя я выделил строгое правило: всегда вызывать компонент и всегда добавлять and() на переходах любых проверок. Но в вашем случае выбор остается за вами, действуйте как удобнее вам.

Шаг 8. Финальный вид теста.

Рефакторинг фреймворка завершен. Теперь осталась самая интересная часть - наблюдать за преобразованием!

Тестовый класс теперь выглядит так:

@SpringBootTest
public class ContractSubmissionTest {
@Autowired
Page page;
@Autowired
TestSteps steps;

private ContractSectionComponent contract;
private InsurantSectionComponent insurant;
private SummarySectionComponent summary;
private PaymentSectionComponent payment;
private CalculationSectionComponent calculation;

    @BeforeEach
    void initComponents() {
         contract = page.getContractSectionComponent();
         insurant = page.getInsurantSectionComponent();
         summary = page.getSummarySectionComponent();
         payment = page.getPaymentSectionComponent();
         calculation = page.getCalculationSectionComponent();
    }

// … тесты
}

А теперь посмотрим, как изменились тесты. Напомню ещё раз, как наш тест выглядел вначале:

@Test
@DisplayName("Тест: оформление договора")
public void testContractSubmission() {
Page page = new Page();

TestData data = TestDataBuilder.getDefault().build();

authService.login();
page.openPage();
  
// заполняем форму         
page
    .selectInsuranceCompany(data.getInsuranceCompany())
    .enterInsurancePolicyNumber(data.getInsurancePolicyNumber())
    .enterInsuranceContractStartDate(data.getInsuranceStartDate())
    .enterInsuranceContractEndDate(data.getInsuranceEndDate())
    .enterLastName(data.getLastname())
    .enterFirstName(data.getFirstname())
    .enterMiddleName(data.getMiddlename())
    // + остальные поля для заполнения формы страхователя

    .selectVehicleBrandFromTheDropdown(data.getBrand())
    .selectVehicleModelFromTheDropdown(data.getModel())
    .enterVehicleYearOfProduction(data.getYearOfProduction())
    .enterVehiclePrice(data.getPrice())
    // + остальные поля для заполнения параметров автомобиля          

page.clickCalculateButton();
Assertions.assertThat(page.isSuccessCalculationNotificationDisplayed())
    .as("Нотификация об успешном расчете должна быть отображена")
    .isTrue();
 Assertions.assertThat(page.getContractStatusLabel().getText())
    .as("Договор должен быть переведен в статус Проект")
    .isEqualTo(ContractStatus.PROJECT.getText());

page.clickSubmitButton();
Assertions.assertThat(page.isSuccessSubmissionNotificationDisplayed())
    .as("Нотификация об успешном оформлении должна быть отображена")
    .isTrue();
Assertions.assertThat(page.getContractStatusLabel().getText())
    .as("Договор должен быть переведен в статус Оформленный")
    .isEqualTo(ContractStatus.ISSUED.getText());
Assertions.assertThat(page.getPriceLabel().getText())
    .as("Сумма страховой премии должна совпадать с ожидаемой")
    .isEqualTo(data.getInsurancePrice());
Assertions.assertThat(page.isPaymentHeaderDisplayed())
    .as("Блок оплаты должен быть отображен на странице")
    .isTrue();
Assertions.assertThat(page.getInsuranceProgram().getText())
    .as("Программа страхования должна совпадать с ожидаемой")
    .isEqualTo(data.getInsuranceProgram());
}

А теперь взглянем, как он выглядит сейчас после преобразований:

@Test
@DisplayName("Тест: оформление договора")
public void testContractSubmission() {
TestData data = TestDataBuilder.getDefault().build();

steps
    .login()
    .openPage()
    .fillData(testData)
    .calculate()
    .submit();

assertThat(page)
    .summary().matchesInsurancePriceValue(data.getInsurancePrice()).and()
    .payment().showsHeader().and()
    .calculation().matchesInsuranceProgramName(data.getInsuranceProgram());
}

Ну не красота ли?! :)

Объем кода значительно снизился, читаемость выросла, а писать новые тесты для оформления договора теперь станет проще и быстрее. Любой, кто взглянет на этот тест, сможет понять, что в нём происходит. И вы в том числе, когда вернетесь к этим тестам спустя время.

Q&A

Предвижу некоторые вопросы, которые могли возникнуть по ходу материала.

Давайте рассмотрим всё по порядку.

1. У меня страница не делится на компоненты, как быть? Обязательно делать компоненты?

Нет.

Совсем необязательно в каждом проекте насильно вводить компоненты. Сначала посмотрите на структуру страницы: если это плоская форма из двух-трёх полей и пара кнопок, добавление десяти отдельных классов действительно выглядит избыточным. Смысл компонентного подхода - изолировать кусок интерфейса, который развивается сам по себе: таблица с фильтрами, многошаговый визард, блок итогов и т.п.

Если страница относительно монолитная, но вы всё равно хотите уйти от простыни, начните с двух простых шагов:

Выделите компоненты там, где они уже явно читаются из макета (например, форма клиента, информация о договоре, кнопки действий). Даже два-три компонента уже дают выигрыш в читаемости.

Остальное оставьте на уровне страницы, но используйте те же принципы: инкапсулируйте локаторы, давайте методам осмысленные названия, возвращайте this.

Если со временем блоки начнут разрастаться - их будет проще выделить в полноценные компоненты: код и так уже подготовлен к декомпозиции.

2. Обязательно ли использование Spring?

Нет.

Spring - это всего лишь удобный способ управлять зависимостями, но не обязательная часть DSL-подхода. Его плюсы в данном примере:

  • контейнер сам создаёт страницу, компоненты и шаги, так что тесту не нужно вручную инициализировать десяток объектов

  • легко подменять зависимости (например, разные сервисы) и переиспользовать их между тестами

  • удобно работать с жизненным циклом (например, через @PostConstruct или разные скоупы)

Но если в проекте Spring нет или тащить его не хочется, можно реализовать те же идеи даже простыми фабриками. Главное - сохранить архитектурную декомпозицию:

  • страница агрегирует компоненты

  • шаги скрывают бизнес‑сценарии

  • assertion‑слой предоставляет fluent‑интерфейс

Просто создавайте объекты вручную, и DSL будет работать ровно так же.

3. В сложных тестах не получится абсолютно всё складывать в красивые цепочки и действовать по схеме «данные -> действия -> проверки». Иногда бывают промежуточные проверки, а иногда в рамках теста надо буквально кликнуть на что-то, но атомарный степ делать вроде как нельзя. Цепочки прервутся, а действия в тесте будут на разных уровнях абстракции. Это ок?

Это абсолютно нормальная ситуация. DSL - не догма, а удобный формат для большинства сценариев. В реальных тестах всегда найдутся места, где приходится выйти из красивой схемы «данные -> действия -> проверки»:

  • нужно сделать разовую проверку прямо посреди сценария (например, убедиться, что горит индикатор, прежде чем продолжать)

  • есть редкое действие, ради которого не хочется плодить отдельный шаг или компонент (разовый клик по системной модалке, закрытие попапа и тому подобное)

  • сценарий ветвится и к части шагов хочется привязать собственные промежуточные asserts

Главное - контролировать уровни абстракции. Несколько принципов, которые помогают не скатиться обратно в простыню:

  1. Тест по‑прежнему инкапсулирует логику. Он решает, когда нужно сделать промежуточный assert, но выражает его через понятные методы компонентов/Assertion‑слоя. Если проверки действительно уникальные - ничего страшного, что они появляются напрямую в тесте.

  2. Редкие действия можно выполнять напрямую через компонент. В тесте допустимо вызвать summary.clickRefreshButton(), если это разовый случай. Просто убедитесь, что действие реализовано на уровне компонента, а не через голый локатор.
    Именно поэтому мы и добавляли в тестовый класс @BeforeEach с инициализацией компонентов — чтобы обращаться к ним в тестах напрямую, если это потребуется.

  3. Где нужно смешивайте уровни осознанно. Небольшая связка
    calculate();
    calculation.clickChangeProgramButton();
    станет понятной опорой, даже если цепочка разорвалась.

  4. Фиксируйте страничные дыры. Если какой‑то полуручной кусок сценария повторяется - значит, это уже кандидат на новый шаг или assert‑метод. DSL живой, выстраивайте его вокруг реальных паттернов.

Так что да, цепочки будут прерываться - в этом нет ничего плохого. Главное, чтобы каждая операция была на понятном уровне абстракции, а весь сценарий по-прежнему читался как история пользователя, а не набор XPath-ов.

Минусы DSL-подхода

С DSL тесты выглядят хорошо и пишутся легко. Однако будет честным заметить, что такой подход работает не для каждого проекта.

1. Дополнительные уровни абстракции

DSL добавляет уровни абстракции между тестом и Page Object (проверки, степы). Это означает больше классов, больше кода и больше точек, которые нужно поддерживать.

Например, чтобы написать один лаконичный тест, мы создали:

  • компонентную модель,

  • страницу‑агрегатор,

  • слой Steps

  • assertion‑слой

  • component‑assert классы

Объём инфраструктуры может быть значительным.

Если в проекте 10 UI‑тестов, такая архитектура будет избыточной.

2. Стоимость поддержки

DSL нельзя «написать и забыть», его нужно развивать вместе с приложением: добавлять новые классы проверок, поддерживать существующие, следить за тем, чтобы DSL оставался понятным и не превращался в ещё один слой магии.

DSL‑фреймворк требует дисциплины и понимания архитектуры. Если этим не заниматься, он быстро деградирует и начинает мешать.

3. Риск чрезмерной абстракции

Иногда возникает соблазн спрятать абсолютно всё:

steps.createDefaultContractAndSubmit();

Тест превращается в одну строку. В этот момент он перестаёт быть тестом - он становится вызовом сценарного скрипта. Слишком высокий уровень абстракции убивает прозрачность.

4. Избыточность для небольших проектов

Не небольших проектах с десятком UI-тестов подобная архитектура, очевидно, можется оказаться просто лишней.

Классические Page Object + обычные assert'ы могут быть проще и дешевле в поддержке.

5. Замедление разработки на старте

На раннем этапе проекта DSL‑подход замедляет:

  • нужно продумать структуру

  • выделить компоненты

  • определить границы ответственности

  • спроектировать assertion‑слой

А бизнесу иногда нужно просто «быстро покрыть регресс». В такой ситуации глубокая архитектура может быть преждевременной. Но не лишней. Не спешите ставить точку окончательно и убирать идею с DSL в долгий ящик.

На опыте своего проекта скажу, что я действовал как раз по такому сценарию. Задача, которая стояла передо мной в первые месяцы на проекте, - быстро покрыть автоматизацией то, что имелось. Закрыв покрытие критического ��ункционала, я быстро принялся за оптимизацию кода. Здесь важно не оттягивать этот момент слишком долго, чтобы потом не пришлось переписывать тонны кода и делать мега-рефакторинг.

6. Входной порог для команды

Новому инженеру нужно понять структуру компонентов, устройство assertion-слоя, правила использования DSL, особенно если он не работал с этим раньше.

Что в итоге?

Подводя финальную черту, хочу сказать, что DSL и fluent-подход помогли мне решить несколько практических проблем:

  1. Тесты стали короче и легче читаются.

  2. Я трачу меньше времени на добавление новых тестов, если они добавляются в рамках существующих компонентов страницы.

  3. Реализован компонентный подход вместо огромного монолитного Page Object.

  4. Простыня из методов объекта страницы заменена на понятные читаемые степы.

  5. Бизнес‑проверки отделились от технических деталей UI.

  6. Повторяющиеся проверки перехали в assertion-слой.

  7. Изменения в UI локализуются внутри компонентов и проверок, а не размазываются по тестам.

Но думая об этом, взвесьте все «за» и «против». Все минусы перечислены выше. Повторюсь ещё раз о том, когда DSL начинает приносить пользу:

  1. UI‑сценариев становится много.

  2. Проверки начинают повторяться.

  3. Поддержка тестов (а не структуры) начинает занимать значительное время.

  4. Команде важна читаемость.

В моем случае именно рост тестового набора стал точкой, где простые решения перестали работать комфортно.

Надеюсь, данный материал был полезен вам!