Урок 10. Page Object Model и другие паттерны
POM, Component Object, Screenplay
Темы урока
Слой абстракции над локаторами, Page Object Model, Component Object Model, Screenplay Pattern, антипаттерны POM
Видео урока
Конспект урока
Конспект — Урок 10: Page Object Model на профиле PomidorQA
Главное за урок
До этого урока каждый тест сам искал поля: getByLabel("Имя"), getByRole("button", { name: "Сохранить" }). Скопировали в пять тестов — и живём.
Потом дизайнер меняет «Сохранить» на «Сохранить изменения». Продукт работает. Краснеют все пять тестов. Playwright ни при чём: один и тот же селектор лежал в каждом файле.
Page Object Model (POM) — паттерн проектирования. Страница сайта становится классом. Локаторы и действия живут в одном месте. Тест больше не знает, как называется кнопка. Он говорит: «сохрани имя».
На нашем примере это класс ProfilePage для экрана /pomidorqa/profile.
Рядом с POM есть ещё два паттерна: Component Object (объект не на страницу, а на кусок UI) и Screenplay (актёр выполняет задачи). Их нужно узнать, в домашку пишем страницу.
Как выглядит тест без Page Object
Вот тест «сменить имя» так, как мы писали его на уроках 8–9. Всё в одном файле: регистрация, поиск полей, клики, проверка.
test("имя: вводим новое и сохраняем", async ({ page }) => {
const user = makeUser("qa", Date.now());
await registerUser(page, user);
await page.goto("/pomidorqa/profile");
const newName = `Анна ${Date.now()}`;
await page.getByLabel("Имя").fill(newName);
await page.getByRole("button", { name: "Сохранить" }).click();
await expect(page.getByLabel("Имя")).toHaveValue(newName);
});
Три проблемы:
getByLabel("Имя")написан дважды в одном тесте. В соседнем — ещё раз. Поменяли подпись поля — правишь пачку мест.- Тест читается как инструкция по DOM, не как кейс. «Найди лейбл, заполни, найди кнопку, кликни».
makeUserиregisterUserлежат в том же spec, что и клики. Поменял фабрику — ищешь по файлу.
POM лечит первые две. Хелпер — третью.
Что такое паттерн и что такое Page Object
Паттерн проектирования — готовый способ разложить код, когда одна и та же боль повторяется. Не библиотека и не команда Playwright. Просто договор: «страницу прячем в класс».
Page Object — класс, который изображает одну страницу.
| Внутри класса | Зачем | На профиле |
|---|---|---|
| Конструктор | принимает page браузера |
new ProfilePage(page) |
| Поля | локаторы, один раз | поле «Имя», кнопка «Сохранить», чип навыка |
| Методы | то, что человек делает на экране | открыть профиль, сохранить имя, добавить навык |
Тест после этого выглядит так:
const profile = new ProfilePage(page);
await profile.goto();
await profile.saveName(newName);
await expect(profile.nameInput()).toHaveValue(newName);
Селекторов в тесте больше нет. Если лейбл «Имя» переименуют — правишь одну строку в ProfilePage. Тесты не трогаешь.
Документация Playwright рисует ровно эту схему: https://playwright.dev/docs/pom
Словарь, без которого класс не читается
Если class ещё пугает — хватит семи слов.
| Слово | Простыми словами | У нас |
|---|---|---|
| Класс | чертёж. Сам по себе ничего не открывает | class ProfilePage { ... } |
| Объект / экземпляр | живая копия по чертежу | const profile = new ProfilePage(page) |
| Поле | данные внутри объекта | nameInput — локатор поля «Имя» |
| Метод | действие | saveName, addSkill, goto |
| Конструктор | код, который бежит в момент new |
принимает page, запоминает локаторы |
this |
«вот этот объект» | this.page, this.nameInput |
| Инкапсуляция | детали спрятаны | тест не пишет getByLabel, вызывает метод |
ProfilePage в файле — чертёж. new ProfilePage(page) — конкретная страница в этой вкладке. Две вкладки — два объекта, у каждого свой page.
Собираем ProfilePage по шагам
Файл: tests/pages/profile-page.ts.
Шаг 1. Класс и конструктор
Конструктор получает page — ту самую вкладку, которую Playwright даёт тесту.
import { type Page } from "@playwright/test";
export class ProfilePage {
constructor(readonly page: Page) {}
}
readonly page — коротко: «положи page в объект, больше не перезаписывай». Дальше везде this.page.
Шаг 2. Открыть страницу
URL тоже прячем. Тесту не обязательно помнить путь.
async goto() {
await this.page.goto("/pomidorqa/profile");
}
Шаг 3. Действие «сохранить имя»
Имя метода — смысл кейса, не клик. Не clickSave, а saveName.
async saveName(name: string) {
await this.page.getByLabel("Имя").fill(name);
await this.page.getByRole("button", { name: "Сохранить" }).click();
}
Локаторы живут здесь. Поменяли текст кнопки — правишь эту строку.
Шаг 4. Локатор для проверки
Объект кликает. Тест проверяет. Чтобы expect не писал getByLabel сам, класс отдаёт готовый локатор.
nameInput() {
return this.page.getByLabel("Имя");
}
Так можно и полем в конструкторе: this.nameInput = page.getByLabel("Имя"). Оба варианта нормальные. Важно одно: getByLabel не торчит в spec.
Шаг 5. Целиком
// tests/pages/profile-page.ts
import { type Page } from "@playwright/test";
export class ProfilePage {
constructor(readonly page: Page) {}
async goto() {
await this.page.goto("/pomidorqa/profile");
}
async saveName(name: string) {
await this.page.getByLabel("Имя").fill(name);
await this.page.getByRole("button", { name: "Сохранить" }).click();
}
nameInput() {
return this.page.getByLabel("Имя");
}
}
Остальные поля профиля — тот же шаблон: Telegram, часовой пояс, «О себе», навык. Для навыка удобен метод addSkill(name), внутри — fill + выбор типа + «Добавить».
Как выглядит тот же тест с Page Object
import { test, expect } from "@playwright/test";
import { makeUser, registerUser } from "../helpers/user";
import { ProfilePage } from "../pages/profile-page";
test("имя: вводим новое и сохраняем", async ({ page }) => {
const user = makeUser("qa", Date.now());
await registerUser(page, user);
const profile = new ProfilePage(page);
await profile.goto();
const newName = `Анна ${Date.now()}`;
await profile.saveName(newName);
await expect(profile.nameInput()).toHaveValue(newName);
});
Сравни с первым листингом. Смысл кейса тот же. Селекторов в тесте нет.
| Было в тесте | Стало |
|---|---|
page.goto("/pomidorqa/profile") |
profile.goto() |
page.getByLabel("Имя").fill(...) + click «Сохранить» |
profile.saveName(newName) |
expect(page.getByLabel("Имя")) |
expect(profile.nameInput()) |
В profile-flow.spec.ts после рефакторинга не должно остаться getBy*, locator(), CSS и XPath. Только методы страницы и expect.
Проверка живёт в тесте, не в классе
// так
await profile.saveName(newName);
await expect(profile.nameInput()).toHaveValue(newName);
// не так — expect спрятан внутри saveName
await profile.saveName(newName); // упало «где-то в методе», непонятно что проверяли
Класс делает шаг. Тест решает, что должно получиться. Иначе красный отчёт врёт: «ошибка в saveName», а сломалось значение в поле.
Отдать локатор наружу (nameInput()) — нормально. Проверку (toHaveValue) пишет тест.
Хелпер — это не Page Object
makeUser и registerUser тоже надо вынести из spec, но это не страница.
Хелпер отвечает на вопрос «кто такой пользователь и как он появляется в системе». Страница отвечает на вопрос «что можно сделать на этом экране».
Хелпер tests/helpers/user.ts |
Страница tests/pages/profile-page.ts |
|
|---|---|---|
| Что внутри | тип, фабрика email, регистрация | локаторы и действия профиля |
| Пример | makeUser("qa", Date.now()) |
profile.saveName("Анна") |
| Чего нет | полей профиля | фабрики почты |
Регистрация — вход в тест, не кейс «сменить имя». Поэтому она в хелпере, не в ProfilePage.
Один файл user.ts. Его импортирует profile-flow.spec.ts. Копипасты фабрики в тесте больше нет.
// tests/helpers/user.ts
export type TestUser = {
name: string;
email: string;
password: string;
};
export function makeUser(role: string, runId: number): TestUser {
return {
name: `${role} Автотест`,
email: `${role}-${runId}@example.com`,
password: "testpass123",
};
}
export async function registerUser(page: Page, user: TestUser) {
await page.goto("/pomidorqa/auth/register");
await page.getByLabel("Имя").fill(user.name);
await page.getByLabel("Email").fill(user.email);
await page.getByLabel("Пароль").fill(user.password);
await page.getByRole("button", { name: "Зарегистрироваться" }).click();
await expect(page).toHaveURL(/\/pomidorqa\/?$/);
}
toHaveURL здесь — «регистрация закончилась, мы на главной». Это конец шага входа, не проверка кейса профиля. В ProfilePage expect не кладём.
Другие паттерны: не только POM
POM — классика. На собесе спросят, будто других способов нет. Есть.
Component Object — объект на кусок экрана
Страница большая. На ней повторяется один и тот же блок.
Профиль PomidorQA состоит из формы (имя, Telegram, «О себе») и блока навыков (поле + тип + «Добавить» + чипы). Навык — отдельный кусок UI. Его можно вынести в свой класс, не в «всю страницу».
const skills = new SkillsForm(page);
await skills.add("Playwright");
await expect(skills.chip("Playwright")).toBeVisible();
| Page Object | Component Object | |
|---|---|---|
| На что класс | целый экран | кусок экрана |
| Пример | ProfilePage |
SkillsForm |
| Когда брать | страница одна и короткая | блок живёт на двух экранах или страница уже толстая |
Страница может держать компонент внутри: ProfilePage создаёт SkillsForm и отдаёт наружу addSkill.
Для нашего профиля одного ProfilePage хватает: навык больше нигде не трогаем. Компонент нужно знать, в домашку его не пишем.
Screenplay — актёр и задачи
Тест читается как сценарий: «студент добавляет навык». Не «страница кликает Добавить».
const student = new Student(page);
await student.attemptsTo(new AddSkill("Playwright"));
Три роли:
| Роль | Кто это | У нас |
|---|---|---|
| Актёр | кто действует | Student |
| Задача | что делает | AddSkill |
| Страница | как нажать | внутри задачи вызывается ProfilePage |
Имеет смысл, когда ролей несколько (хост и гость) и сценарии длинные. На одном пользователе и коротком профиле — рано и тяжело.
POM не единственный правильный ответ. Screenplay — другой способ разложить тот же тест. В домашку его не копируем.
Что ломает конструкцию
| Ошибка | Как выглядит | Почему плохо |
|---|---|---|
| God Object | один класс на регистрацию, профиль, календарь и бронь | файл на 400 строк; меняешь слот — боишься задеть логин |
| Класс есть, локаторы в тесте | завёл ProfilePage, в spec снова page.getByLabel("Имя") |
объект для галочки: UI поменяли — снова правишь пачку тестов |
| Методы-клики | clickSave, clickAdd без смысла кейса |
тест снова про DOM, не про «сохранить имя» |
expect внутри метода |
saveName сам проверяет value |
упало в методе — не видно, какой результат ждали |
Дроби по смыслу: данные в хелпере, профиль в ProfilePage. Бронь — отдельная страница, если возьмёшься за звёздочку.
Как сделать домашку
Из profile-flow.spec.ts вынести наружу две вещи.
- Пользователь и регистрация →
tests/helpers/user.ts. - Локаторы и действия профиля →
class ProfilePageвtests/pages/profile-page.ts.
В тесте остаются вызовы методов и expect. Локаторов нет.
⭐ То же для брони: хелпер + BookingPage, переписать booking-flow.spec.ts.
Ветка hw10-<github-username> → PR в main.
Ключевые тезисы для теста
- POM — паттерн: одна страница сайта = один класс с локаторами и методами.
- Зачем: селектор в одном месте. Поменяли кнопку — правишь класс, не десять тестов.
- Класс — чертёж. Объект —
new ProfilePage(page). - Метод называется как действие человека:
saveName, неclickSave. expectпишет тест. Класс может отдать локатор (nameInput()), но не проверяет сам.- Хелпер (
makeUser,registerUser) — данные и вход. Это не страница. - Component Object — класс на кусок UI (
SkillsForm), не на весь экран. - Screenplay — актёр выполняет задачи. Нужен на длинных сценариях с несколькими ролями.
- God Object — один класс на весь сайт. Так не делаем.
- Завёл
ProfilePageи снова пишешьgetByLabelв тесте — паттерн не работает.
Домашнее задание
Индивидуальная проверка ДЗ — на Boosty
Конспект, видео и тест открыты всем. Текст задания и проверка работы — по подписке.
Индивидуальная проверка ДЗ на Boosty