К списку уроков
Блок 5 · Архитектура и стабильность тестов

Урок 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);
});

Три проблемы:

  1. getByLabel("Имя") написан дважды в одном тесте. В соседнем — ещё раз. Поменяли подпись поля — правишь пачку мест.
  2. Тест читается как инструкция по DOM, не как кейс. «Найди лейбл, заполни, найди кнопку, кликни».
  3. 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 вынести наружу две вещи.

  1. Пользователь и регистрация → tests/helpers/user.ts.
  2. Локаторы и действия профиля → 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
База для QA — вопросы на собеседование, тренажёры и материалы