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

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

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

    Зміст

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

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

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

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

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

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

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

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

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

    Класичний 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, щоб деталь реалізації не протікала назовні, — але це наша конвенція, а не вимога джерела: офіційний приклад Playwright, навпаки, оголошує локатори публічними readonly і дає тесту звертатися до них напряму. По-друге, дії названі мовою користувача (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 поля, ми вважаємо протікаючою абстракцією. Але чесно: джерельної опори в цього правила немає. Офіційний приклад Playwright і імпортує expect у файл page object, і перевіряє публічний локатор прямо в тесті — тож це наша домовленість, а не галузевий консенсус.

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

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

    Фаулер — власна позиція автора. Мартін Фаулер спершу чесно наводить аргументи обох сторін, а свій висновок формулює від першої особи: «I favor having no assertions in page objects» — я віддаю перевагу, а не так треба. Логіка: робота об'єкта — надати доступ до стану сторінки, а вирішувати, що з цього стану правильне, — робота тесту; так об'єкт лишається перевикористовним і в позитивному, і в негативному сценарії, а expect живе поряд з описом очікуваного результату. Виняток він теж називає від першої особи — перевірка інваріантів (чи це взагалі та сторінка і чи вона коректно завантажилась) доречна навіть у «безасертному» стилі, на відміну від перевірок конкретного сценарію.

    // 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('Невірні дані');
    });

    Selenium — нормативний припис із одним обов'язковим винятком. Офіційна дока Selenium говорить зовсім іншою модальністю: «Page objects themselves should never make verifications or assertions» — ніколи, і це вимога, а не перевага. Але тут же вона вводить рівно один виняток, і він теж нормативний: є «one, single, verification which can, and should, be within the page object» — перевірка, що сторінка і критичні елементи на ній завантажилися коректно, причому зробити її треба під час створення об'єкта. Тобто інваріант із виноски Фаулера тут стає обов'язком. Підсумковий список правил у тій самій доці, до речі, м'якшає до «Generally don’t make assertions» — «загалом».

    Playwright — практика в офіційному прикладі, без жодного припису. Дока Playwright норми про не формулює взагалі: вона показує кодом протилежне. В офіційному прикладі expect імпортовано просто у файл page object (import { expect, type Locator, type Page } from '@playwright/test'), перевірка стоїть усередині методу об'єкта (await expect(this.gettingStartedHeader).toBeVisible()), а локатори оголошені публічними readonly, тож тест перевіряє їх напряму: await expect(playwrightDev.tocList).toHaveText([...]). Це важливіше за «джерело мовчить»: канон однієї з двох найпоширеніших док робить рівно те, що канон іншої забороняє.

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

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

    Табір «page object'и не шарити». Є й окрема позиція збоку, про яку часто забувають, — вона не про місце асерту, а про сам спільний об'єкт. Дока Cypress прямо називає спільні page object'и антипатерном (Anti-Pattern: Sharing page objects) — разом із входом через UI і небажанням «зрізати кути». Як Best Practice там радять інше: тестувати специфікації ізольовано, логінитися програмно й керувати станом застосунку напряму. Логіка та сама, що в главі про тест-дані: якщо стан можна підготувати через API, довгий прохід по UI через спільний об'єкт сторінки додає крихкості, а не знімає її. POM це не скасовує, але й безальтернативним не лишає.

    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), меню хедера, — усе розкладається так само. Це і є «від класики до компонентів» — з важливим уточненням: компонентність не еволюція патерна, а його первісне формулювання. Першоджерело з самого початку радить будувати об'єкти не на кожну сторінку, а на значущі елементи сторінки, і навіть визнає назву оманливою — «panel object» пасувало б краще. «Одна сторінка — один клас» — це спрощення, яке додала практика, а не позиція канонічного опису.

    Той самий принцип поза 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 розводить їх (див. «Патерни в автоматизації»).

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

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

    Виглядає як зручний page object, а насправді god object. Термін не наш жаргон, а усталене поняття ООП: обʼєкт, що «references a large number of distinct types, has too many unrelated or uncategorized methods, or some combination of both», і джерело прямо класифікує його як «an example of an anti-pattern and a code smell». Порогів у рядках означення не задає — ознаки якісні, тож наступні числа читайте як ілюстрацію, а не як критерій. На практиці це клас MainPage на 1500 рядків, який знає про хедер, футер, три модалки, таблицю й половину бізнес-логіки. Симптом — будь-яка зміна UI чіпає цей файл, і два інженери постійно ловлять конфлікти в ньому. Лікування — розбити на компоненти; один клас відповідає за один зв'язний шматок екрана (це принцип єдиної відповідальності, канон — у главі «SOLID у тест-фреймворку»).

    Виглядає як інкапсуляція, а насправді протікання — за нашою конвенцією. Метод повертає Locator або WebElement назовні, і тест робить .click() уже сам. Формально локатор у класі — але деталь реалізації знову в тесті. За нашим правилом page object має віддавати дані й стан, а всі взаємодії тримати всередині; офіційний приклад Playwright цього правила не тримає, тож це домовленість команди, а не універсальна норма.

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

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

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

    Підсумок

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

    Можливі питання

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

    Джерела

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

    • Martin Fowler — PageObject — сама постановка проблеми: тести, що напряму торкають HTML, крихкі до змін UI, а page object обгортає сторінку застосунковим API.
    • Selenium — Page object models — дві названі вигоди (чисте розділення тестового коду й коду сторінки, єдине сховище операцій) і наскрізний інваріант: рівно одне місце в сюїті знає структуру HTML цієї частини сторінки.
    • ISTQB® CTAL-TAE Syllabus v2.0 — §3.1.5: цінність сформульована як «updates in only one place, the locator inside a page model».

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

    • Playwright — Page Object Models — офіційний приклад на TypeScript: клас інкапсулює операції сторінки, локатори оголошено публічними readonly, і expect імпортовано просто у файл page object.
    • Martin Fowler — PageObject — критерій правильної межі — тест на заміну контрола: якщо конкретний віджет замінили, інтерфейс page object змінюватися не повинен; операції мають повертати фундаментальні типи або інші page object.
    • Playwright — Locators — чому оголошувати локатори в конструкторі безпечно: локатор це спосіб знайти елемент у будь-який момент, а не вже знайдений елемент.

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

    • Selenium — Page object models — нормативний бік холівару: «Page objects themselves should never make verifications or assertions», з єдиним дозволеним винятком — перевіркою завантаження сторінки при створенні обʼєкта.
    • Martin Fowler — PageObject — авторська позиція, а не припис: «I favor having no assertions in page objects», і тут же названий виняток на перевірки інваріантів сторінки.
    • Playwright — Page Object Models — третя модальність: жодного припису про асерти, зате сам приклад тримає expect у файлі page object.
    • Cypress — Best Practices — четвертий, окремий табір: «Anti-Pattern: Sharing page objects, using your UI to log in, and not taking shortcuts» проти «Test specs in isolation, programmatically log into your application, and take control of your applicationʼs state».
    • Playwright — Test assertions — чому холівар менш болючий: web-first асерти самі чекають і повторюють, тож ручні очікування ховати в page object не треба.

    Fluent-інтерфейс

    • Martin Fowler — PageObject — джерело самої конструкції: операції page object повертають фундаментальні типи або інші page object, а різні результати тієї самої дії моделюють різними методами.
    • Selenium — Page object models — те саме правило з боку класики: «Different results for the same action are modelled as different methods».

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

    • Martin Fowler — PageObject — обʼєкти будують на значущі елементи сторінки, а не на сторінку; ієрархія, що існує лише для структурування верстки, в обʼєкти проступати не повинна; автор сам визнає назву оманливою — точнішою була б «panel object».
    • Selenium — Page object models — та сама ідея під власною назвою Page Component Objects: застосунки будують не з монолітної сторінки, а з компонентів у ній.
    • CodeceptJS — Page Objects — реалізація в сценарному стилі: технічно тотожний page object, різниця лише концептуальна.

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

    • Martin Fowler — PageObject — узагальнення, з якого росте API-клієнт: обгортка дає програмному клієнту робити й бачити те саме, що людина, ховаючи деталі носія.
    • Serenity/JS — Screenplay Pattern — критика, названа поіменно: Screenplay розводить ролі, які page object тримає в одному класі.
    • ISTQB® CTAL-TAE Syllabus v2.0 — чому це взагалі питання дизайну: автоматизація тестування — діяльність із розробки ПЗ, тож патерни в ній ті самі, що в продуктовому коді.

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

    • Wikipedia — God object — джерело цитованого означення: обʼєкт, що «references a large number of distinct types, has too many unrelated or uncategorized methods, or some combination of both», класифікований як «an example of an anti-pattern and a code smell» (trust: secondary, dec-0710).
    • PMD — Design rules (God Class) — та сама вада як правило , і головне уточнення: ознаки метричні (складність, звертання до чужих даних, низька звʼязність методів), а не «скільки рядків у файлі».

    Пояснення

    «Поясни» працює з власним API-ключем Anthropic: запит іде з вашого браузера прямо до Anthropic.

    Свій API-ключ (BYOK)

    Вставте власний ключ Anthropic — пояснення працюватиме на реальній моделі. Ключ зберігається лише у вашому браузері (localStorage), нікуди не надсилається, крім api.anthropic.com, і не логується.