| Создание и улучшение Page Object шаг за шагом |
| 14.01.2026 00:00 |
|
Несколько недель назад я провел сессию парного программирования/менторства с человеком, который обратился ко мне за поддержкой, считая, что ему это необходимо. Когда я впервые увидел код, который он написал, я был впечатлен. Конечно, были моменты, которые я бы сделал иначе, но в основном это вкусовщина, а не превосходство моего подхода. Вместо того чтобы работать напрямую с его кодом, мы решили вместе создать тестовый код с нуля, по ходу дела обсуждая и применяя хорошие принципы и паттерны программирования. Поскольку тесты использовали Playwright на TypeScript и были сильно ориентированы на работу с графическим интерфейсом, мы решили начать строить структуру на основе Page Object для ключевого компонента их приложения. Это был UI-компонент, позволяющий конечному пользователю создавать отчёт в системе. Точный тип системы или даже сама предметная область на самом деле не имеют большого значения для целей этого поста. Компонент выглядел примерно так, если сильно упрощённо:
В верхней части находилась радиокнопка с тремя вариантами выбора разных макетов отчёта. Каждый макет отчёта состоит из нескольких полей формы, и большинство полей формы – это текстовые области с кнопками блокировки. В полях открывается структура, похожая на выпадающий список. Там можно редактировать права доступа к этому полю, выбирая одну или несколько ролей, которым доступно содержимое поля (это функция конфиденциальности). И, конечно, есть кнопка сохранения отчёта, а также кнопка печати. Фактический UI-компонент имел ещё несколько типов элементов, но для краткости остановимся на этих. Итерация 0 — создание начального Page ObjectМой подход, когда я начинаю с нуля, неважно, самостоятельно или работая с кем-то, заключается в том, чтобы делать маленькие шаги и постепенно наращивать сложность. Может возникнуть соблазн сразу создать Page Object, содержащий поля для всех элементов и методы для взаимодействия с ними, но это очень быстро станет запутанным. Вместо этого мы начали с самого простого Page Object, который только мог прийти нам в голову: позволяющего создать стандартный отчёт, не учитывая кнопки блокировки для настройки прав доступа. Предположим, что стандартный отчёт состоит только из заголовка и текстового поля для краткого содержания. Первая итерация этого Page Object выглядела примерно так: export class StandardReportPage {что делает тест с использованием этого Page Object таким: test('Creating a standard report', async ({ page } ) => {Итерация 1 — группировка взаимодействий с элементамиМой первый вопрос после того, как мы реализовали и использовали этот Page Object, был: «как вы оцениваете читаемость этого теста?». Конечно, мы только что написали этот код, и это маленький пример, но представьте, что вы работаете с Page Objects, где все написаны в таком стиле и предлагают гораздо больше взаимодействий с элементами. Это быстро приведёт к очень процедурному тестовому коду типа «ввести это, ввести то, нажать здесь, проверить там», который не демонстрирует намерение теста явным образом. Другими словами, такой стиль кодирования не очень хорошо скрывает реализацию страницы (даже если скрыты локаторы) и сосредоточен только на поведении. Для улучшения я предложил группировать взаимодействия с элементами, которые формируют логическое взаимодействие конечного пользователя, в один метод и открывать его наружу. Когда я читаю или пишу тест, меня не особенно интересует последовательность отдельных взаимодействий с элементами, необходимых для выполнения действия высокого уровня. Меня не интересует «заполнить текстовое поле» или «нажать кнопку», меня интересует «создать стандартный отчёт». Это привело нас к рефакторингу Page Object следующим образом: export class StandardReportPage {
что, в свою очередь, сделало тест таким: test('Creating a standard report', async ({ page } ) => {
Уже значительно лучше с точки зрения читаемости и принципа «показывай поведение, скрывай реализацию». Кстати, применение этого принципа вовсе не уникально для UI-автоматизации или даже тестирования в целом. Этот принцип называется инкапсуляцией и является одним из фундаментальных принципов объектно-ориентированного программирования. Это принцип, который полезно знать при написании тестового кода, если вы хотите, чтобы ваш код оставался читаемым. Итерация 2 — добавление возможности задавать права доступа для каждого поля формыНа следующем этапе мы решили добавить возможность задавать права доступа для каждого текстового поля. Как объяснено и показано в графическом представлении формы в начале статьи, у каждого поля стандартной формы есть кнопка блокировки, которая открывает небольшое диалоговое окно, где пользователь может выбрать, какие роли пользователей могут и не могут видеть поле отчёта. Изначально мы думали просто добавить дополнительные поля в Page Object, представляющий стандартный отчёт. Однако это привело бы к множеству повторяющейся работы и к тому, что стандартный отчёт имел бы много полей с локаторами элементов. Поэтому мы решили рассмотреть возможность объединения текстового поля отчёта и связанной с ним кнопки блокировки как Page Component, т.е. отдельного класса, который инкапсулирует поведение группы связанных элементов на конкретной странице. Настройка этого в переиспользуемом виде будет гораздо проще, если HTML этих Page Components имеет одинаковую структуру по всему приложению. Хорошая новость в том, что это часто так и есть, особенно когда дизайнеры и фронтенд-разработчики создают и реализуют интерфейсы с использованием инструментов вроде Storybook. Соответствующая часть HTML для стандартной формы может выглядеть так (опять же, упрощённо): <div id="standard_form"> Пример переиспользуемого класса Page Component может выглядеть примерно так: export class ReportFormField {
Обратите внимание, что конструктор этого класса Page Component использует (и фактически полагается на) предсказуемую, повторяющуюся структуру компонента в приложении и наличие атрибутов data-testid. Если у ваших компонентов нет таких атрибутов, найдите способ добавить их или используйте другой универсальный способ для локализации отдельных элементов компонента на странице. Теперь, когда мы определили наш класс Page Component, нужно определить отношение между этими Page Components и Page Object, который их содержит. Раньше я по умолчанию создавал базовые классы Page, которые содержали бы переиспользуемые Page Components, а также другие вспомогательные методы. Более специфичный Page Object затем наследовался бы от этих базовых страниц, что позволяло бы использовать методы, определённые в родительском базовом классе Page. Почти неизменно это в какой-то момент приводило к очень запутанным базовым классам Page с множеством полей и методов, которые были лишь косвенно связаны, в лучшем случае. Причина этого беспорядка? Моя недостаточно чёткая проработка типа отношений между различными Page Objects и Components. Понимаете, создание базовых классов и использование наследования для переиспользования создаёт отношения «является» (is-a). Они полезны, когда отношения между объектами действительно «является». Однако в нашем случае нет отношений «является», есть отношения «имеет» (has-a). Page Object имеет определённый Page Component. Другими словами, нам нужно определить отношение по-другому — с помощью композиции вместо наследования. Мы определяем Page Components как компоненты наших Page Objects, что создаёт гораздо более естественное отношение между ними и делает код значительно более структурированным: export class StandardReportPage {
Такой код гораздо читабельнее, чем попытки впихнуть всё в один или несколько родительских классов, то есть базовых Page Objects. Вывод здесь таков: способ, которым объекты связаны в вашем коде, должен отражать отношения между этими объектами в реальной жизни, то есть в вашем приложении. Итерация 3 — А как насчёт других типов отчётов?Этапы разработки и рефакторинга, которые мы прошли до этого момента, привели к тому, что мы были вполне довольны кодом. Однако у нас всё ещё есть Page Objects только для одного типа формы, и, как вы видели на схеме в начале этого поста, типы форм у нас разные. Как с ними работать? Нужно также учесть, что эти формы имеют общие компоненты и поведения – но и различия между ними тоже есть. Соблазн сразу прыгнуть к выводам и начать навешивать паттерны и структуры велик, но на сессиях парного программирования я обычно стараюсь избегать поиска и реализации «окончательного» решения сразу. Почему? Потому что лучшее обучение происходит, когда вы видите (или создаёте, в данном случае) неоптимальную ситуацию, обсуждаете проблемы этой ситуации, исследуете потенциальные решения и только потом их реализуете. Конечно, это займёт больше времени сначала, но оно с лихвой окупается гораздо лучшим пониманием того, как выглядит неоптимальный код и как его улучшить. Итак, сначала мы создаём отдельные классы для отдельных типов отчётов, каждый из которых похож на реализацию стандартного отчёта, которую мы создали ранее. Вот пример для расширенного отчёта, содержащего больше полей формы (ну, на самом деле только одно дополнительное, но суть ясна): export class ExtendedReportPage {
Очевидно, что между этим классом и Page Object для стандартного отчёта код сильно дублируется. Что с этим делать? В отличие от ситуации с Page Components, здесь имеет смысл уменьшить дублирование, создав базовый Page Object для отчёта. В данном случае мы говорим о создании отношения «является» (наследование), а не «имеет» (композиция). Стандартный отчёт — это и есть отчёт. Это значит, что в данном случае мы можем и должны создать базовый Page Object для отчёта, переместить туда часть (или даже весь) дублированный код и позволить специфическим Page Objects отчётов наследоваться от этого базового класса. Моя рекомендация — сделать базовый Page Object для отчёта абстрактным классом, чтобы предотвратить его прямое создание. Это приводит к более выразительному и понятному коду, так как можно создавать только конкретные подтипы отчётов, что сразу даст читателю кода понимание, с каким типом отчёта он имеет дело. В абстрактном классе мы объявляем элементы, общие для всех отчётов. Это касается методов, но также и веб-элементов, которые встречаются во всех типах отчётов. Абстрактный базовый класс может выглядеть примерно так: export abstract class ReportBasePage {
А конкретный класс для расширенного отчёта, реализующий абстрактный класс, теперь выглядит так: export class ExtendedReportPage extends ReportBasePage {
Абстрактный класс заботится о методах, общих для всех отчётов, таких как print() и select(). Он также определяет, какие элементы и методы должны быть реализованы в конкретных классах-наследниках. На данный момент это только локатор radioSelect. Обратите внимание, что в данный момент, поскольку данные, требуемые для разных типов отчётов, отличаются, мы пока не можем добавить в абстрактный класс требование реализовать метод select(): void для всех Page Objects отчётов. Это временное ограничение, которое мы решим чуть позже. Также отметим, что тестовый код не изменился, но теперь мы можем создавать как стандартный, так и расширенный отчёт, которые за кулисами используют значительную часть общего кода. Определённо шаг в правильном направлении. Итерация 4 — Работа с тестовыми даннымиНаши тесты уже выглядят довольно прилично. Они легко читаются, а структура кода соответствует структуре частей приложения, которые они представляют. Закончена ли работа? Возможно. В качестве последнего улучшения тестов давайте посмотрим, как мы обрабатываем тестовые данные. Сейчас тестовые данные, которые мы используем в тестовых методах, представляют собой просто неструктурированную коллекцию строк, чисел, булевых значений и так далее. Для небольших тестов и простой доменной области, которую легко понять, это может сработать, но как только ваша тестовая база растёт и домен становится более сложным, это начинает путать. Что именно представляет эта строка? Почему эта переменная булева и что произойдёт, если её установить в true (или false)? Здесь на помощь приходят объекты тестовых данных. Объекты тестовых данных — это простые классы, часто ничем не более чем Data Transfer Object (DTO), которые представляют доменную сущность. В данной ситуации такой доменной сущностью может быть, например, отчёт. Использование типов, представляющих доменные сущности, значительно улучшает читаемость кода и облегчает понимание того, что именно мы делаем. Реализация этих объектов тестовых данных часто проста. В TypeScript для этого можно использовать простой интерфейс. Я решил создать один класс ReportContent, который содержит данные для всех наших типов отчётов. По мере их различий я могу переработать это в отдельные интерфейсы, но пока этого достаточно. Кроме того, определение такого объекта тестовых данных даёт дополнительное преимущество: оно позволяет переместить определение метода create() для разных Page Objects отчётов в абстрактный базовый класс, чего ранее сделать не удавалось. Мой интерфейс выглядит следующим образом: export interface ReportContent {
Поле additionalInfo помечено как необязательное, так как оно появляется только в расширенном отчёте, а не в стандартном. В некоторых случаях, чтобы ещё больше повысить гибкость кода, мы можем определить объекты тестовых данных как класс вместо интерфейса. Это позволит задавать свойствам разумные значения по умолчанию, чтобы не приходилось присваивать одни и те же значения этим свойствам в каждом тесте. Установка значений по умолчанию невозможна при использовании интерфейсов в TypeScript. В данном конкретном случае использование интерфейса меня устраивает, потому что объект ReportContent небольшой. В вашем случае ситуация может отличаться. Теперь, когда мы определили тип для данных отчёта, мы можем изменить сигнатуру и реализацию методов create() в наших Page Objects, чтобы использовать этот тип. Вот пример для расширенного отчёта: async create(report: ReportContent) {
И теперь мы можем добавить следующую строку в абстрактный класс ReportBasePage: abstract create(report: ReportContent): void; Это нужно, чтобы обязать все Page Objects отчётов реализовать метод create(), который принимает аргумент типа ReportContent. Тот же подход можно применить и к другим объектам тестовых данных. Это была большая работа, но она привела к коду, который, на мой взгляд, хорошо структурирован, легко читается и поддерживается. Как, надеюсь, показала эта статья, полезно хорошо разбираться в общих принципах и паттернах объектно-ориентированного программирования при написании тестового кода. Это особенно важно для UI-автоматизации, но многие из принципов, рассмотренных в этой статье, можно применить и к другим видам автоматизации тестирования. Существуют и другие паттерны, которые стоит изучить. Статья не претендует на то, чтобы перечислить их все, и не показывает «единственно правильный» способ написания Page Objects. Тем не менее, она должна продемонстрировать мой мыслительный процесс при написании кода для автоматизации тестирования и то, как понимание основ объектно-ориентированного программирования помогает мне делать это лучше. |