vyvchy
    Теми розділу

    06 · Автоматизація: стратегія

    Page Object: від класики до компонентів

    Зміст

    Уяви сюїт із двохсот UI-тестів, у кожному з яких десь усередині написано page.locator('#login-btn').click(). Розробники перейменували кнопку на #signin-btn — і ти правиш той самий рядок у двохстах місцях, ризикуючи пропустити десяток. Це не гіпотетичний жах: саме так виглядає автоматизація без жодного шару абстракції над сторінкою. Локатори (locator) розповзаються по тестах, кроки дублюються, а будь-яка косметична зміна фронтенду перетворює зелений сюїт на червоний не через баг, а через крихкість самих тестів.

    Page Object Model (POM) — це найстаріша і найважливіша відповідь індустрії на цю проблему, і тому на співбесіді middle-рівня питання «розкажи про Page Object» майже неминуче. Але справжня перевірка не в тому, чи знаєш ти означення, а чи розумієш межі патерну: де він рятує, де перетворюється на god object, і чому та сама ідея працює далеко за межами браузера. Ця глава — про механізм, а не про заучену формулу.

    Проблема, яку розв'язує POM

    Тест має відповідати на питання «що перевіряємо», а не «як саме дістатися до елемента». Коли ці два рівні змішані в одному файлі, ти отримуєш дві біди одразу: дублювання і крихкість.

    Дублювання. Логін повторюється на початку десятків тестів. Якщо сценарій входу описаний прямо в тесті — заповнити поле email, заповнити поле пароля, натиснути кнопку — то він скопійований усюди, де потрібен залогінений користувач.

    Крихкість. Локатор #login-btn — це деталь реалізації UI. Коли верстка змінюється, деталь протікає в кожен тест, який її згадує. Тест ламається не тому, що продукт зламався, а тому, що ми прив'язали перевірку бізнес-логіки до випадкового селектора.

    POM розводить ці рівні. Об'єкт сторінки (page object) — це клас, який інкапсулює все знання про конкретний екран: як знайти на ньому елементи і які дії над ним доступні. Тест працює з методами цього класу мовою домену (loginPage.login(user)), а не мовою DOM. Локатор кнопки живе рівно в одному місці. Перейменували кнопку — правиш один рядок в одному класі, і всі двісті тестів лагодяться самі.

    Ключове слово тут — єдине джерело правди (single source of truth). Це прямий наслідок принципу DRY (детальніше — у главі «Принципи дизайну тестового коду») і того самого ООП-мислення, з якого ми почали розділ (див. «ООП на тестовому коді»): клас ховає складність за зрозумілим інтерфейсом.

    Сторінка = локатори + дії

    Класичний page object складається рівно з двох частин: локатори (де що лежить) і дії (що з цим можна зробити). Нічого більше в мінімальній формі не потрібно.

    import { Page, Locator } from '@playwright/test';
    
    export class LoginPage {
      private readonly email: Locator;
      private readonly password: Locator;
      private readonly submit: Locator;
    
      constructor(private readonly page: Page) {
        this.email = page.getByLabel('Email');
        this.password = page.getByLabel('Пароль');
        this.submit = page.getByRole('button', { name: 'Увійти' });
      }
    
      async open(): Promise<void> {
        await this.page.goto('/login');
      }
    
      async login(user: { email: string; password: string }): Promise<void> {
        await this.email.fill(user.email);
        await this.password.fill(user.password);
        await this.submit.click();
      }
    }

    Зверни увагу на три речі. По-перше, локатори — private: тест не має до них лізти напряму, інакше деталь реалізації знову протече назовні. По-друге, дії названі мовою користувача (login, open), а не мовою механіки (clickSubmitButton) — метод описує намір, а не послідовність кліків. По-третє, який саме селектор ми вибрали (getByRole, getByLabel, data-testid) — окреме питання зі своєю главою «Локатори: стратегія стабільних селекторів»; POM лише каже, що це рішення має бути прийняте в одному місці.

    У Playwright є важливий нюанс, який спрощує життя: Locator — «лінивий». Він не шукає елемент у момент створення в конструкторі, а лише зберігає рецепт пошуку і виконує його щоразу під час дії. Тому оголошувати локатори в конструкторі безпечно навіть до того, як сторінка завантажилась. У класичному Selenium інакше: @FindBy/PageFactory дають WebElement — посилання на конкретний знайдений вузол DOM, і коли сторінка перемальовується, воно «протухає». Звідси класична пастка StaleElementReferenceException, якої лінивий локатор Playwright не має, бо перешукує елемент під кожну дію.

    Важлива дисципліна: page object не робить expect, але і не вертає сирі елементи. Він повертає дані або стан, а не Locator. Метод getErrorText(): Promise<string> — добре; метод, що віддає назовні Locator поля, — погано, бо це та сама протікаюча абстракція.

    Перевірки в page object: холівар

    Ось найгарячіша суперечка навколо POM, і на співбесіді відповідь «залежить» тут не відмазка, а правильна позиція — якщо ти можеш пояснити, від чого саме залежить.

    Табір «page object без асертів». Мартін Фаулер у канонічній статті радить: page object не повинен містити перевірок. Його робота — надати доступ до стану сторінки; вирішувати, що з цього стану правильне, — робота тесту. Аргументи: об'єкт сторінки стає перевикористовним у будь-якому сценарії (і в позитивному, і в негативному), а expect живе там, де йому місце — у тесті, поряд з описом очікуваного результату. Сам Фаулер робить один виняток: перевірка інваріантів (чи це взагалі та сторінка і чи вона коректно завантажилась) у page object доречна — на відміну від перевірок конкретного сценарію.

    // PO лише віддає стан
    async getErrorMessage(): Promise<string> {
      return (await this.errorBanner.textContent()) ?? '';
    }
    
    // А перевірка — у тесті
    test('вхід з чужим паролем показує помилку', async ({ page }) => {
      const login = new LoginPage(page);
      await login.open();
      await login.login({ email: 'a@b.co', password: 'wrong' });
      expect(await login.getErrorMessage()).toContain('Невірні дані');
    });

    Табір «перевірки-методи всередині». Практики заперечують: винести назовні геть усі перевірки означає, що прості асерти дублюються в багатьох тестах. Компроміс — виносити в page object дрібні assert-методи, які інкапсулюють складне очікування (наприклад, «дочекатися, поки таблиця перемалюється, і переконатися, що рядок з'явився»), але тримати бізнес-перевірки в тестах.

    Практичний орієнтир, який рятує в обох світах: web-first assertions Playwright роблять цей холівар менш болючим. Оскільки expect(locator).toHaveText(...) сам чекає й ретраїть, спокуса ховати ручні очікування всередині page object слабшає — можна віддати Locator через тонкий геттер лише для асерту й перевіряти в тесті без сирого waitFor. Головне правило залишається: не змішуй у методі і дію, і перевірку її результату — інакше метод не перевикористаєш у сценарії, де очікується протилежне. Це прямо перегукується з принципом атомарності з глави «Анатомія автотесту».

    Fluent-інтерфейс

    Fluent-інтерфейс (fluent interface) — стиль, коли методи повертають об'єкт, щоб виклики зчіплювались у ланцюжок, який читається майже як речення. У Selenium-світі (особливо на Java) це популярний прийом:

    loginPage
      .typeEmail('a@b.co')
      .typePassword('secret')
      .submit();

    Щоб так працювало, кожен метод повертає this. Виглядає гарно, але в асинхронному JS/TS має підводний камінь: методи Playwright — async, тож повертають Promise, і чистого синхронного зчеплення не вийде — доведеться повертати Promise<this> й городити await (await po.typeEmail()).typePassword(), що читається гірше, ніж звичайні окремі await. Тому в Playwright «класичний» fluent через this майже не використовують.

    Натомість є набагато корисніша форма fluent-ідеї — повертати наступний page object з методу навігації. Метод, який виконує перехід між екранами, віддає об'єкт того екрана, куди ти потрапив:

    async login(user: User): Promise<DashboardPage> {
      await this.email.fill(user.email);
      await this.password.fill(user.password);
      await this.submit.click();
      return new DashboardPage(this.page);
    }
    
    // У тесті потік читається як маршрут користувача:
    const dashboard = await new LoginPage(page).login(user);
    await expect(dashboard.welcomeHeading).toBeVisible();

    Це не косметика: такий підхід кодує в типах дозволені переходи між сторінками. Компілятор не дасть викликати метод дашборда до того, як ти реально «увійшов». Мінус — жорсткіша зв'язність (coupling): page object логіну тепер знає про існування дашборда. Тому повертати наступну сторінку варто там, де перехід однозначний, і не варто там, де метод може вести в кілька різних станів.

    Від сторінок до компонентів

    «Одна сторінка — один клас» ламається об реальність сучасного вебу. Хедер, футер, таблиця, модалка, віджет фільтрів повторюються на десятках екранів. Якщо кожен page object містить власні локатори хедера, ти знову дублюєш — просто на рівень вище.

    Розв'язання — компонентні об'єкти (component objects): той самий патерн, але одиниця інкапсуляції не сторінка, а шматок UI. Page object при цьому не успадковує компоненти, а містить їх — це композиція (has-a), а не наслідування (is-a). Різниця принципова, і чому саме композиція тут краща — тема глави «Принципи дизайну тестового коду».

    DashboardPage

    HeaderComponent

    TableComponent

    ConfirmModal

    OrdersPage

    TableComponent

    SettingsPage

    DashboardPage

    HeaderComponent

    TableComponent

    ConfirmModal

    OrdersPage

    TableComponent

    SettingsPage

    Хедер описаний раз і перевикористаний трьома сторінками. Ключ до компонента — скоупити пошук у межах його кореня, а не по всій сторінці, інакше два однакові компоненти на одному екрані конфліктуватимуть:

    export class TableComponent {
      constructor(private readonly root: Locator) {}
    
      row(text: string): Locator {
        return this.root.getByRole('row').filter({ hasText: text });
      }
    
      async sortBy(column: string): Promise<void> {
        await this.root.getByRole('columnheader', { name: column }).click();
      }
    }
    
    export class OrdersPage {
      readonly orders: TableComponent;
      readonly header: HeaderComponent;
    
      constructor(page: Page) {
        this.orders = new TableComponent(page.getByTestId('orders-table'));
        this.header = new HeaderComponent(page.getByRole('banner'));
      }
    }

    TableComponent приймає свій кореневий Locator і всі пошуки веде від нього (this.root.getByRole(...)). Тому один і той самий клас обслуговує таблицю замовлень, таблицю користувачів і будь-яку іншу — треба лише передати інший корінь. Модалка підтвердження (ConfirmModal), меню хедера, пагінація — усе розкладається так само. Це і є «від класики до компонентів»: класичний POM оперував цілими екранами, компонентний масштабується під сучасні SPA, зібрані з переасамбльованих блоків.

    Той самий принцип поза UI: API-клієнти

    Найглибша частина відповіді на співбесіді — усвідомлення, що Page Object не про сторінки. Це окремий випадок ширшого патерну: сховати деталі протоколу за фасадом мовою домену. Прибери браузер — і той самий принцип дає API-клієнт.

    Тест, який дьорбає ендпоінти напряму, страждає рівно від тих самих бід, що й тест із розсипаними локаторами: URL і форма запиту дублюються, зміна контракту ламає десятки місць. Рішення ізоморфне page object'у — клас, що інкапсулює знання про API:

    export class UsersApi {
      constructor(private readonly request: APIRequestContext) {}
    
      async create(user: NewUser): Promise<User> {
        const res = await this.request.post('/api/users', { data: user });
        return res.json();
      }
    }

    UsersApi.create() — це рівно те саме, що LoginPage.login(): метод мовою домену ховає деталь протоколу (там — селектор, тут — URL і метод HTTP). Такий клієнт незамінний для підготовки стану через API замість повільного проходу по UI (згадай про це в главах про тест-дані та піраміду тестування). Механіка HTTP і форматів даних — у главі «REST API та формати даних»; тут важливий сам факт спільного кореня. Побачивши цей зв'язок, ти розумієш і чому Page Object критикують: він змішує в одному класі три ролі (локатори, дії, іноді перевірки), і патерн Screenplay розводить їх (див. «Патерни в автоматизації»).

    Типові помилки

    Виглядає як зручний page object, а насправді god object. Клас MainPage на 1500 рядків, який знає про хедер, футер, три модалки, таблицю й половину бізнес-логіки. Симптом — будь-яка зміна UI чіпає цей файл, і два інженери постійно ловлять конфлікти в ньому. Лікування — розбити на компоненти; один клас відповідає за один зв'язний шматок екрана (це принцип єдиної відповідальності, канон — у главі «SOLID у тест-фреймворку»).

    Виглядає як інкапсуляція, а насправді протікання. Метод повертає Locator або WebElement назовні, і тест робить .click() уже сам. Формально локатор у класі — але деталь реалізації знову в тесті, і сенс POM втрачено. Page object має віддавати дані й стан, а всі взаємодії тримати всередині.

    Виглядає як усунення дублювання, а насправді жорстка ієрархія. BasePageAuthorizedPageAdminPageAdminUsersPage на чотири рівні угору. Глибоке наслідування крихке: зміна в базовому класі непередбачувано зачіпає нащадків. Майже завжди краще передати спільне як компонент (композиція), а не тягнути його вниз спадком.

    Виглядає як бізнес-мова, а насправді переклад кліків. Методи названі clickLoginButton(), enterTextInEmailField(). Це той самий імперативний код, лише перенесений в інший файл. Метод page object має описувати намір користувача (login, searchFor, removeFromCart), інакше абстракція нульова.

    Перевірки й дії в одному методі. Метод loginAndCheckSuccess() не можна перевикористати в негативному сценарії. Дія і асерт її результату — різні відповідальності; тримай їх окремо.

    Підсумок

    • Page Object інкапсулює локатори + дії одного екрана за фасадом мовою домену; тест говорить намірами, а не селекторами.
    • Головна цінність — єдине джерело правди для локатора: зміна верстки лагодиться в одному місці, а не в кожному тесті.
    • Класичний холівар «перевірки в PO» вирішується правилом: page object віддає стан, тест вирішує, що правильне; не змішуй дію і асерт її результату в одному методі.
    • Сучасний POM масштабується компонентами через композицію (has-a), а не наслідуванням (is-a) чи god object'ом на всю сторінку.
    • Це не патерн про UI: API-клієнт — той самий Page Object, що ховає деталь протоколу за методом мовою домену.

    Що питають на співбесіді

    • «Що таке Page Object і яку проблему він розв'язує?» Інтерв'юер хоче почути про дублювання і крихкість, а не завчене означення. Сильна відповідь одразу називає «єдине джерело правди для локаторів» і показує, що ти розумієш чому, а не лише що.
    • «Чи має page object містити асерти?» Класична пастка на позицію. Правильно — розкрити холівар: показати обидва табори (Фаулер проти, практики за дрібні перевірки-методи) і сформулювати власне робоче правило. Відповідь «ні, ніколи» без пояснення слабша за «залежить, і ось від чого».
    • «Як не роздути page object до god object?» Дивляться, чи знаєш ти про компоненти й композицію. Згадай скоупінг пошуку в межах кореня компонента і принцип єдиної відповідальності.
    • «Композиція чи наслідування для спільних елементів?» Очікувана відповідь — композиція; треба вміти пояснити, чому глибокі ієрархії BasePage крихкі.
    • «Page Object — лише для UI?» Питання на глибину. Хто бачить, що API-клієнт — той самий патерн, показує системне розуміння, а не заучену механіку.

    Джерела

    Тема не входить у силабус ISTQB CTFL 4.0 — це інженерна практика автоматизації, а не частина базової теорії тестування; канонічні джерела тут — документація інструментів і першоджерело патерну.