Патерни в автоматизації: Screenplay, Builder, Factory, Facade
Зміст
Ти відкриваєш чужий тестовий проєкт і бачиш класи LoginTask, UserBuilder, BrowserFactory. Три незнайомі імені — але якщо ти знаєш патерни, то вже здогадуєшся, що кожен робить, ще не читаючи коду. У цьому і сенс патерна: це спільна мова, якою інженери називають рішення, що повторюються. На співбесіді на AQA чи SDET патерни питають не тому, що чекають переказу класичної книжки напам'ять, а щоб перевірити, чи відрізняєш ти «додав абстракцію, бо була реальна біль» від «додав абстракцію, бо так у книжці написано».
Ця глава — про чотири патерни, які справді трапляються в автоматизації (Screenplay, Builder, Factory, Facade), про Singleton, з яким найчастіше стріляють собі в ногу, і про головну пастку — патерн заради патерну. Page Object ми вже розібрали окремо (/avtomatyzatsiia/page-object-vid-klasyky-do-komponentiv); тут дивимось на нього як на точку відліку.
Патерн — це словник, а не готовий код
Термін «патерн проєктування» (design pattern) прийшов не з програмування, а з архітектури: Крістофер Александер описував повторювані рішення для будівель і міст. У 1994-му «банда чотирьох» (Gang of Four, GoF) — Гамма, Гелм, Джонсон і Вліссідес — перенесла ідею в ООП, зібравши 23 патерни у три групи:
| Група | Що вирішує | Приклади з глави |
|---|---|---|
| Породжувальні (creational) | як створювати об'єкти | Builder, Factory, Singleton |
| Структурні (structural) | як складати об'єкти у більші структури | Facade |
| Поведінкові (behavioral) | як об'єкти взаємодіють між собою | Screenplay близький сюди |
Головне непорозуміння новачка: патерн — це не бібліотека і не шматок коду, який можна заімпортувати. Патерн — це назва для рішення в певному контексті. Коли ти кажеш «зробимо через Builder», колега розуміє задум за одне слово, і це вже цінність. Screenplay, до речі, у GoF немає взагалі: він народився саме в тест-автоматизації, тому його часто плутають з класичними патернами.
І одразу застереження, до якого повернемось наприкінці: патерн має з'являтися як відповідь на біль — дублювання, крихкість, конструктор на п'ятнадцять параметрів, — а не тому, що «так правильно». Патерн без проблеми, яку він розв'язує, — це чиста складність без вигоди. Про цю межу — глави про DRY/KISS/YAGNI (/avtomatyzatsiia/pryntsypy-dyzainu-testovoho-kodu-dry-kiss-yagni-kompozytsiia) і про SOLID як overengineering (/avtomatyzatsiia/solid-u-test-freimvorku).
Screenplay проти Page Object
Page Object групує локатори й дії навколо сторінки. Це працює, поки сторінок мало. На великих сюїтах POM повзе в бік god object: клас на півтори тисячі рядків, глибокі ієрархії BasePage, дублювання між схожими екранами. Screenplay (інша історична назва — Journey) пропонує інший центр тяжіння — не сторінку, а актора (actor), тобто користувача з його намірами. Патерн виріс із доповідей Ентоні Маркано (Antony Marcano) й Енді Палмера (Andy Palmer) і реалізований у фреймворках родини Serenity (Serenity/JS, Serenity BDD).
Модель складається з п'яти понять:
- Актор (actor) — хто діє: конкретна персона чи роль (
admin,guest). - Здібності (abilities) — чим актор володіє:
BrowseTheWeb,CallAnApi. Це обгортки над інструментами. - Задачі (tasks) — бізнес-дії високого рівня («залогінитись», «оформити замовлення»), складені з інших задач та інтеракцій.
- Інтеракції (interactions) — атомарні дії:
Click,Enter,Navigate. - Питання (questions) — читають стан системи, щоб живити перевірки.
У коді (стиль Serenity/JS) тест читається майже як сценарій:
await actor.attemptsTo(
Navigate.to('/login'),
Enter.theValue('qa@example.com').into(LoginForm.email),
Enter.theValue('secret').into(LoginForm.password),
Click.on(LoginForm.submit),
);
await actor.attemptsTo(
Ensure.that(Text.of(Dashboard.heading), equals('Ласкаво просимо')),
);
Ключова перевага — композиція. Дрібні інтеракції збираються у задачі, задачі — в більші задачі, і кожна дрібниця перевикористовується:
const LogIn = {
as: (email: string, password: string) =>
Task.where(`#actor логіниться як ${email}`,
Navigate.to('/login'),
Enter.theValue(email).into(LoginForm.email),
Enter.theValue(password).into(LoginForm.password),
Click.on(LoginForm.submit),
),
};
await actor.attemptsTo(LogIn.as('qa@example.com', 'secret'));
Чому це взагалі виникло: Screenplay набагато строгіше тягне за принципами SOLID, ніж класичний POM. Кожна задача, інтеракція й питання — маленький клас з однією відповідальністю, а система будується композицією, а не успадкуванням (канон «композиція проти успадкування» — /avtomatyzatsiia/pryntsypy-dyzainu-testovoho-kodu-dry-kiss-yagni-kompozytsiia). Плата за це — більше церемоній і крутіша крива входу.
| Аспект | Page Object | Screenplay |
|---|---|---|
| Центр моделі | сторінка | актор (користувач) |
| Основний механізм | успадкування, методи класу | композиція дрібних задач |
| Ролі/персони | незручно, окремі методи | природно: різні актори |
| Поріг входу | низький | вищий, більше коду |
| Ризик god object | високий | нижчий за побудовою |
| Де доречно | більшість проєктів | великі довговічні сюїти з ролями |
Практичний висновок без фанатизму: більшість команд на Playwright чи CodeceptJS живуть на POM, і це нормальний, достатній вибір. Screenplay виправданий, коли акторів багато (різні ролі й персони), сюїта велика й довговічна, а команда готова платити за структуру. Тягнути Screenplay у проєкт із тридцяти тестів — уже класичний патерн заради патерну.
Builder: складні дані без конструктора-жаху
Уяви модель User з п'ятнадцятьма полями. Створювати її в кожному тесті через new User('qa@example.com', 'secret', 'admin', true, null, 'UA', ...) — це шум: тест перелічує пів моделі, хоча йому важливе одне поле, а позиційні аргументи легко переплутати місцями.
Builder — породжувальний патерн: він будує складний об'єкт крок за кроком і ховає розумні дефолти. У тестах його найчастіша форма — test data builder з fluent-інтерфейсом, де ти задаєш лише те, що важливо саме для цього кейсу:
const user = aUser()
.withRole('admin')
.withEmail('qa@example.com')
.build();
Тест каже «мені потрібен адмін», а не диктує всі поля. Дефолти живуть в одному місці — у білдері; додав нове обов'язкове поле в модель — правиш білдер, а не двісті тестів. Fluent-ланцюжок читається зверху вниз і не залежить від порядку аргументів.
Детально фабрики й білдери тест-даних (faker, ізоляція, підготовка стану) — окрема канонічна глава (/avtomatyzatsiia/test-dani-fabryky-fikstury-izoliatsiia). Тут нам важливий сам патерн і його межа: білдер без розумних дефолтів вироджується у звичайні сеттери й нічого не економить.
Factory: хто вирішує, що створити
Factory (фабрика) — родина породжувальних патернів (фабричний метод, абстрактна фабрика). Її суть: інкапсулювати рішення про те, який саме об'єкт створити, за одну дію. Виклик просить «дай мені браузер» чи «дай мені клієнт», а деталі того, який конкретний клас підставити, ховаються всередині фабрики:
type BrowserName = 'chromium' | 'firefox' | 'webkit';
function browserFor(name: BrowserName): BrowserType {
switch (name) {
case 'firefox': return firefox;
case 'webkit': return webkit;
default: return chromium;
}
}
const browser = await browserFor(process.env.BROWSER as BrowserName).launch();
Тест (чи фікстура) не знає й не хоче знати про if/else вибору рушія — це рішення інкапсульоване в одному місці.
Builder і Factory часто плутають, бо обидва «створюють об'єкти». Різниця у питанні, на яке вони відповідають:
| Factory | Builder | |
|---|---|---|
| Питання | який об'єкт створити | як зібрати складний об'єкт |
| Кроки | один крок | покроково, з опціональними полями |
| Типовий результат | одна з кількох реалізацій | один складний об'єкт |
| Маркер вибору | вибір із варіантів (браузер, клієнт) | багато опціональних полів і дефолтів |
Простий орієнтир: багато опціональних полів — бери Builder; треба обрати одну реалізацію з кількох — бери Factory.
Facade: одна проста дія замість п'яти
Facade (фасад) — структурний патерн: простий інтерфейс поверх складної підсистеми. Клієнт не знає про п'ять класів усередині — він викликає один зрозумілий метод.
У тестах фасад присутній усюди, часто неявно. Метод loginPage.loginAs(user), який всередині робить navigate, заповнює два поля, натискає кнопку й чекає редиректу, — це вже фасад. По суті крокові методи Page Object і є фасадами над локаторами. Ставши на цю думку явно, ти можеш зібрати фасад над кількома сторінками:
class Checkout {
constructor(private readonly page: Page) {}
async buy(product: string): Promise<void> {
await new Catalog(this.page).addToCart(product);
await new Cart(this.page).proceedToCheckout();
await new Payment(this.page).payWithSavedCard();
}
}
Тест пише await new Checkout(page).buy('Клавіатура') замість десятка кліків — він читається мовою бізнесу, а коли флоу оформлення змінюється, правка живе в одному місці. Небезпека протилежна: фасад-«бог», який знає забагато й тягне пів застосунку в один клас, перетворюється на ту саму god-object проблему, від якої фасад мав рятувати (/avtomatyzatsiia/page-object-vid-klasyky-do-komponentiv).
Singleton і чому конфіг — небезпечна глобальна змінна
Singleton — породжувальний патерн: гарантує, що клас має рівно один екземпляр, і дає до нього глобальний доступ. Спокуса очевидна: один драйвер, один конфіг, один логер на весь прогін. Саме тому Singleton — найпопулярніший патерн для пострілу собі в ногу, а найгірший його підвид — глобальний мутабельний конфіг.
Чому з ним обережно:
- Ламає ізоляцію. Автотест має бути незалежним (/avtomatyzatsiia/anatomiia-avtotestu-aaa-nezalezhnist-atomarnist). Тест, що змінив глобальний
Config.get().baseURLчи поточного юзера, тихо протікає в наступні тести — і ти отримуєш падіння, що залежить від порядку виконання. - Вбиває паралель. Під час паралельного запуску воркери — це окремі процеси чи контексти (/avtomatyzatsiia/paralelizatsiia-vorkery-shardinh-masshtabuvannia). Один спільний драйвер-singleton означає, що кілька тестів б'ються за одну вкладку. Плаваючі падіння гарантовані.
- Ховає залежності. Із сигнатури тесту не видно, що він спирається на глобальний стан. Читаєш тест — і не розумієш, звідки взявся baseURL чи токен.
- Заважає підміні. Дати одному тесту іншу роль чи інше середовище, коли конфіг глобальний і єдиний, — біль.
Read-only незмінний конфіг (прочитали змінні оточення раз і більше не чіпаємо) — ще терпимо. А от мутабельний глобальний конфіг — це прямий генератор флаку.
Правильна відповідь у сучасних фреймворках — впровадження залежностей (dependency injection) через фікстури замість глобальної змінної. У Playwright worker-scoped фікстура дає кероване спільне використання: фреймворк сам вирішує область життя (scope) й ізоляцію, і кожен тест явно оголошує, що йому потрібно.
export const test = base.extend<{}, { workerConfig: Config }>({
workerConfig: [async ({}, use) => {
await use(loadConfig()); // один екземпляр на воркер, без глобальної змінної
}, { scope: 'worker' }],
});
test('замовлення видно в кабінеті', async ({ workerConfig }) => {
// конфіг приходить явно, а не через Config.get()
});
Це той самий «один екземпляр на процес», але без глобального доступу: залежність видно в сигнатурі, а область життя контролює ранер.
Патерн заради патерну
Найчастіша помилка після знайомства з патернами — тягнути їх усюди. Абстрактний інтерфейс з єдиною реалізацією, фабрика, що обгортає один клас, Screenplay на двадцять тестів, шар «на майбутнє», якого ніхто не просив, — усе це overengineering. Кожен зайвий патерн додає стрибків по файлах і когнітивного навантаження, не даючи натомість нічого.
Орієнтир простий: спершу біль — потім патерн. Одна копія коду — це не привід для абстракції; правило трьох каже задуматись, коли ти скопіпастив утретє. Патерн виправдовує себе, коли знімає конкретне дублювання чи крихкість, а не коли додає «правильності». Це прямий наслідок YAGNI (/avtomatyzatsiia/pryntsypy-dyzainu-testovoho-kodu-dry-kiss-yagni-kompozytsiia): не будуй того, що ще не потрібно.
Типові помилки
- Виглядає як чистий код за патернами, а насправді — абстрактний шар з однією реалізацією, який лише додає стрибків по файлах. Передчасна абстракція, яку варто прибрати.
- Виглядає як Builder, а насправді — набір сеттерів без дефолтів і без
build(). Білдер без розумних дефолтів не економить нічого й лише подовжує код. - Виглядає як Factory, а насправді — той самий
switchна пів екрана, скопійований у кожен тест. Фабрику проголосили, але не винесли, дублювання лишилось. - Виглядає як зручний Singleton-конфіг, а насправді — джерело флаку в паралелі: два тести правлять один об'єкт і залежать від порядку.
- Виглядає як Screenplay, а насправді — POM, перейменований на «Tasks»: задачі лізуть напряму до локаторів, ані акторів, ані здібностей немає. Карго-культ без вигоди патерна.
- Виглядає як Facade, а насправді — god object, що тягне пів застосунку в один клас і знає забагато.
Підсумок
- Патерн — це спільна назва для рішення в контексті, а не імпортований код; головна його цінність — передбачуваність для команди.
- POM простий і page-центричний; Screenplay actor-центричний, строгий за SOLID і виправданий на великих сюїтах з ролями, але надлишковий на малих.
- Builder беруть, коли багато опціональних полів і потрібні дефолти; Factory — коли треба обрати одну реалізацію з кількох. Плутати їх — типова помилка.
- Facade ховає складність підсистеми за простим методом; його межа — не перетворитися на god object.
- Singleton і глобальний мутабельний конфіг ламають ізоляцію й паралель; у Playwright їх заміняють фікстури (dependency injection). Спершу біль — потім патерн.
Що питають на співбесіді
- «Чим Screenplay відрізняється від Page Object і коли оберете який?» Інтерв'юер дивиться, чи розумієш тредоф, а не чи знаєш модне слово. Сильна відповідь: POM простіший і достатній для більшості проєктів; Screenplay — композиція дрібних задач навколо актора, кращий на великих сюїтах з ролями, ціна — церемонії й поріг входу.
- «У чому різниця між Builder і Factory?» Очікують чіткий маркер: багато опціональних полів покроково — Builder; вибір однієї реалізації за раз — Factory.
- «Де у ваших тестах Singleton і чому це небезпечно?» Чекають згадку ізоляції й паралельного запуску; бонус — «глобальний конфіг заміняю фікстурами / DI».
- «Наведіть патерн, який ви прибрали як зайвий.» Перевірка на карго-культ. Хороший кандидат розповідає, як спростив overengineered код, а не хвалиться кількістю патернів.
- «Що таке патерн проєктування?» (trainee-рівень) Очікувана відповідь — «названий рецепт розв'язання типової проблеми в контексті», а не «щось із класичної книжки».
Червоний прапорець для інтерв'юера — кандидат, який додає патерни всюди й не може назвати їхню ціну.
Джерела
- Gamma, Helm, Johnson, Vlissides. «Design Patterns: Elements of Reusable Object-Oriented Software» (1994) — першоджерело GoF-патернів Builder, Factory, Facade, Singleton.
- Refactoring Guru — каталог патернів — доступний довідник з класифікацією та прикладами.
- Serenity/JS — Screenplay Pattern — офіційна документація патерну Screenplay (актори, здібності, задачі, інтеракції, питання).
- Martin Fowler — PageObject — канонічний опис Page Object як точки відліку для порівняння.
Примітка: патерни проєктування не входять до силабусу ISTQB CTFL 4.0 — це інженерна тема поза фундаментальним рівнем; дотичну архітектуру автоматизації оглядово торкається силабус ISTQB CT-TAE (Test Automation Engineer), але без конкретних GoF-патернів.
Що таке патерн проєктування (design pattern)?
Патерн — це названий рецепт розв'язання типової проблеми в конкретному контексті, а не шматок коду, який можна заімпортувати. Ідея прийшла з архітектури (Крістофер Александер про будівлі й міста), а «банда чотирьох» (Gang of Four, GoF) у 1994-му перенесла її в ООП, зібравши 23 патерни. Головна цінність патерна — не сам код, а спільна мова: коли кажеш колезі «зробимо через Builder», він розуміє задум за одне слово, ще не бачачи реалізації. На співбесіді слабка відповідь — «це щось із класичної книжки»; сильна — «це передбачуване рішення для повторюваної ситуації, яке команда впізнає за назвою».
На які три групи діляться GoF-патерни?
Породжувальні (creational), структурні (structural) і поведінкові (behavioral). Породжувальні відповідають на питання «як створювати об'єкти» — сюди йдуть Builder, Factory, Singleton. Структурні — «як складати об'єкти в більші конструкції», приклад із теми автоматизації — Facade. Поведінкові — «як об'єкти взаємодіють між собою». Практична користь класифікації в тому, що вона одразу підказує природу патерна: якщо чуєш «Factory» — знай, це про створення, а не про поведінку. Окремий нюанс: Screenplay у каталозі GoF відсутній — він народився вже в тест-автоматизації, тому його часто помилково ставлять в один ряд із класичними патернами.
Чим Screenplay відрізняється від Page Object?
Різниця в центрі моделі: Page Object групує локатори й дії навколо сторінки, а Screenplay — навколо актора (actor), тобто користувача з його намірами. POM спирається на успадкування й методи класу, Screenplay — на композицію дрібних задач. Модель Screenplay складається з п'яти понять: актор, здібності (abilities), задачі (tasks), інтеракції (interactions) і питання (questions). У коді тест читається майже як сценарій: актор «намагається зробити» послідовність кроків. За рахунок композиції дрібні інтеракції збираються в задачі, задачі — в більші задачі, і кожна деталь перевикористовується. Плата — більше церемоній і вищий поріг входу, ніж у POM.
З яких понять складається модель Screenplay?
П'ять цеглинок. Актор — хто діє, конкретна персона чи роль (admin, guest). Здібності — чим актор володіє: BrowseTheWeb, CallAnApi, тобто обгортки над інструментами. Задачі — бізнес-дії високого рівня (Login, Checkout), складені з інших задач та інтеракцій. Інтеракції — атомарні дії: Click, Enter, Navigate. Питання — читають стан системи, щоб живити перевірки. Ключова властивість цієї структури: кожна задача, інтеракція чи питання — маленький клас з однією відповідальністю, а система будується композицією, а не глибокою ієрархією BasePage. Саме тому Screenplay значно строгіше тягне за принципами SOLID, ніж класичний POM.
Коли обрати Screenplay, а коли лишитися на Page Object?
Орієнтир — розмір, тривалість життя сюїти й кількість ролей. Більшість команд на Playwright чи CodeceptJS живуть на POM, і це достатній, нормальний вибір: він простий, дешевий на вході, зрозумілий джуну. Screenplay виправданий, коли акторів багато (різні ролі й персони природно моделюються окремими акторами), сюїта велика й довговічна, а команда готова платити за структуру церемоніями й крутішою кривою входу. На проєкті з тридцяти тестів Screenplay — це вже класичний «патерн заради патерну»: додає стрибків по файлах, не знімаючи жодної реальної болі. На співбесіді інтерв'юер перевіряє саме розуміння тредофу, а не знання модного слова.
Що таке Builder і навіщо він у тестах?
Builder — породжувальний патерн, який будує складний об'єкт крок за кроком і ховає розумні дефолти. Уяви модель User на п'ятнадцять полів: створювати її через new User('qa@example.com', 'secret', 'admin', true, null, ...) — це шум, бо тест перелічує пів моделі, хоча йому важливе одне поле, а позиційні аргументи легко переплутати місцями. Найчастіша форма в тестах — test data builder з fluent-інтерфейсом: aUser().withRole('admin').build(). Кейс декларує лише важливе («потрібен адмін»), решту дає білдер. Дефолти живуть в одному місці; додав нове обов'язкове поле в модель — правиш білдер, а не двісті тестів. Важлива межа: білдер без розумних дефолтів вироджується в набір сеттерів і нічого не економить.
Що таке Factory і яку задачу вона вирішує?
Factory (фабрика) — родина породжувальних патернів (фабричний метод, абстрактна фабрика), суть якої — інкапсулювати рішення про те, який саме об'єкт створити, за одну дію. Виклик просить «дай мені браузер» чи «дай мені клієнт», а деталі того, який конкретний клас підставити, ховаються всередині фабрики. Типовий приклад в автоматизації — функція browserFor(name), яка за іменем рушія (chromium, firefox, webkit) повертає потрібний тип браузера. Тест чи фікстура не знає й не хоче знати про switch вибору рушія — це рішення зібране в одному місці. Пастка: якщо фабрику «проголосили», але той самий switch скопіювали в кожен тест — дублювання лишилось, а патерна фактично немає.
У чому різниця між Builder і Factory?
Обидва створюють об'єкти, тому їх часто плутають, але вони відповідають на різні питання. Factory відповідає на питання який об'єкт створити — вибір однієї реалізації з кількох за один крок (браузер, API-клієнт). Builder відповідає на питання як зібрати складний об'єкт — покроково, з опціональними полями й дефолтами, результат зазвичай один складний об'єкт. Простий маркер для співбесіди: багато опціональних полів, які задаєш поступово, — бери Builder; треба обрати одну реалізацію з набору варіантів — бери Factory. Плутати їх — типова помилка, яку інтерв'юер якраз і ловить.
Що таке Facade і де він уже присутній у ваших тестах?
Facade (фасад) — структурний патерн: простий інтерфейс поверх складної підсистеми, коли клієнт викликає один зрозумілий метод замість того, щоб знати про п'ять класів усередині. У тестах фасад є всюди, часто неявно. Метод loginPage.loginAs(user), який усередині робить navigate, заповнює два поля, натискає кнопку й чекає редиректу, — це вже фасад. Фактично будь-який кроковий метод сторінки виступає фасадом поверх набору локаторів. Свідомо ставши на цю думку, можна зібрати фасад над кількома сторінками — наприклад, Checkout.buy(product), що всередині проходить каталог, кошик і оплату. Тест тоді читається мовою бізнесу, а правка флоу живе в одному місці. Небезпека протилежна: фасад-«бог», який тягне пів застосунку в один клас, перетворюється на ту саму god-object проблему, від якої мав рятувати.
Що таке Singleton і чому в тестах з ним стріляють собі в ногу?
Singleton — породжувальний патерн, що гарантує рівно один екземпляр класу й дає до нього глобальний доступ. Приваблює він простотою: тримати єдиний драйвер, конфіг чи логер на весь прогін здається очевидно зручним. Але саме він — найпопулярніший патерн для пострілу собі в ногу, а найгірший підвид — глобальний мутабельний конфіг. Він ламає ізоляцію: тест, що змінив глобальний Config.get().baseURL чи поточного юзера, тихо протікає в наступні тести, і ти отримуєш падіння, залежне від порядку виконання. Він вбиває паралель: кілька воркерів б'ються за один спільний драйвер чи об'єкт. Він ховає залежності: із сигнатури тесту не видно, що той спирається на глобальний стан. Read-only незмінний конфіг ще терпимо, а мутабельний глобальний — прямий генератор флаку.
Чому глобальний мутабельний конфіг особливо небезпечний при паралельному запуску?
Під час паралельного прогону воркери — це окремі процеси чи контексти, і вони мають бути ізольовані. Спільний мутабельний об'єкт (драйвер-singleton, глобальний конфіг) стає точкою, за яку вони б'ються: один тест править baseURL чи поточну роль, інший у цей момент читає вже змінене значення, і результат залежить від того, хто встиг першим. Наслідок — плаваючі падіння, які неможливо стабільно відтворити й які часто прив'язані до порядку виконання чи кількості воркерів. Симптом упізнаваний: поодинці тест зелений, у повному наборі під паралелізмом — червоніє. Тому глобальний стан і паралель — принципово несумісні речі.
Як у Playwright замінити Singleton-конфіг без глобальної змінної?
Правильна відповідь у сучасних фреймворках — впровадження залежностей (dependency injection) через фікстури. У Playwright worker-scoped фікстура дає кероване спільне використання: фреймворк сам вирішує область життя (scope) й ізоляцію, а кожен тест явно оголошує в сигнатурі, що йому потрібно. Конфіг завантажується один раз на воркер усередині фікстури з scope: 'worker' і приходить у тест параметром workerConfig, а не через Config.get(). Це той самий «один екземпляр на процес», що й Singleton, але без глобального доступу: залежність видно в сигнатурі, а область життя контролює ранер, а не глобальна змінна. Результат — та сама економія на створенні, але без протікання стану й без несумісності з паралеллю.
Що таке «патерн заради патерну» і як не потрапити в цю пастку?
Це найчастіша помилка після знайомства з патернами — тягнути їх усюди: абстрактний інтерфейс з єдиною реалізацією, фабрика поверх одного класу, Screenplay на двадцять тестів, шар «на майбутнє», якого ніхто не просив. Усе це overengineering: кожен зайвий патерн додає стрибків по файлах і когнітивного навантаження, не даючи натомість нічого. Правило одне: абстракцію виправдовує біль, а не естетика. Одна копія коду не є приводом для абстракції; правило трьох каже задуматись, коли скопіпастив утретє. Патерн виправдовує себе, коли знімає конкретне дублювання чи крихкість, а не коли додає абстрактної «правильності». Це прямий наслідок YAGNI: не будуй того, що ще не потрібно.
Чим Screenplay пов'язаний з SOLID і принципом «композиція проти успадкування»?
Screenplay набагато строгіше тягне за SOLID, ніж класичний POM, і це головна архітектурна причина його існування. Кожна задача, інтеракція й питання — маленький клас з однією відповідальністю (принцип єдиної відповідальності), а система будується композицією дрібних задач, а не глибокими ієрархіями BasePage. Це якраз канон «віддавай перевагу композиції над успадкуванням»: замість того щоб нарощувати спадкову піраміду, ти складаєш поведінку з незалежних цеглинок, кожна з яких перевикористовується окремо. POM у цьому сенсі природно дрейфує в бік god object і глибокого успадкування, тоді як Screenplay має нижчий ризик god object за самою побудовою. Плата за цю строгість — більше коду й церемоній.
Наведіть патерн, який ви прибрали як зайвий. Що перевіряє це питання?
Це перевірка на карго-культ: інтерв'юер хоче почути, що кандидат уміє не лише додавати абстракції, а й прибирати їх, коли вони не окупаються. Хороший приклад — абстрактний інтерфейс з однією реалізацією, фабрика, що обгортає єдиний клас, або «Screenplay», який насправді був POM, перейменованим на «Tasks», де задачі лізуть напряму до локаторів без акторів і здібностей. Сильна відповідь описує, як спрощення прибрало стрибки по файлах і зробило код зрозумілішим, а не хвалиться кількістю застосованих патернів. Червоний прапорець для інтерв'юера — навпаки, кандидат, який додає патерни всюди й не може назвати їхню ціну.
Ти бачиш клас із задачами, які напряму смикають локатори. Це Screenplay?
Ні — це «виглядає як Screenplay, а насправді POM під новою вивіскою». Ознака справжнього Screenplay — актори зі здібностями й композиція: задачі складаються з інтеракцій та інших задач, а не звертаються до локаторів напряму. Якщо в коді немає ані акторів, ані здібностей, а «задачі» — це просто перейменовані методи сторінки, що самі клікають по елементах, то патерн узятий карго-культом: назви від Screenplay є, а вигоди (композиція, ролі, строгий SOLID) — немає. Це та сама пастка «патерн заради патерну»: церемонії додали, користі не отримали. Практичний висновок — назва в коді нічого не гарантує, дивитися треба на структуру.
Три ситуації, у яких патерн або окупається, або перетворюється на баласт: таблиця вибору «симптом → патерн», рефакторинг глобального конфіга-Singleton у worker-фікстуру Playwright, і розбір карго-культу, коли назви від Screenplay є, а вигоди немає. Скрізь — не «як правильно за книжкою», а що саме вирішуємо і чим платимо.
Кейс 1. Таблиця рішень: який патерн під який симптом
Патерн має з'являтися як відповідь на біль, а не тому, що «так правильно». Перш ніж тягнути абстракцію, назви симптом — і звір його з таблицею. Якщо жоден рядок не підходить, найчастіше правильна дія — не додавати патерн узагалі.
| Симптом у коді | Патерн-відповідь | Чим платиш |
|---|---|---|
| Конструктор моделі на 15 полів, тести перелічують пів об'єкта заради одного поля | Builder (test data builder) | Треба тримати розумні дефолти в одному місці |
Потрібно обрати одну реалізацію з кількох (chromium/firefox/webkit, різні API-клієнти) | Factory | Зайва, якщо реалізація одна |
| Тест робить десяток кліків через кілька сторінок, щоб оформити замовлення | Facade (Checkout.buy(...)) | Ризик god object, якщо фасад знатиме забагато |
| Велика довговічна сюїта з багатьма ролями, POM повзе в god object | Screenplay | Церемонії, вищий поріг входу |
| Один драйвер/конфіг/логер на прогін | worker-scoped фікстура (DI), НЕ Singleton | Трохи більше коду у визначенні фікстури |
| Одна копія коду, «а раптом знадобиться ще» | нічого — це YAGNI | — |
Як цим користуватися на практиці. Прийшов симптом «у кожному тесті new User(...) на пів екрана» — це рядок Builder, беремо fluent-білдер. Прийшов симптом «поодинці зелено, у наборі червоно, і результат залежить від порядку» — це не привід для патерна, це привід прибрати глобальний Singleton-стан (кейс 2). Прийшов симптом «у нас тридцять тестів і одна роль, але хочеться Screenplay» — жодного рядка таблиці немає, отже, патерн не потрібен. Правило трьох тут головний фільтр: одна-дві копії коду абстракції ще не вимагають, задуматись варто на третій.
Кейс 2. Рефакторинг: глобальний Singleton-конфіг → worker-фікстура
Класичний генератор флаку — глобальний мутабельний конфіг. Виглядає зручно, а насправді протікає між тестами. Ось як він ламається:
// config.ts — Singleton із глобальним доступом
class Config {
private static instance: Config;
baseURL = 'https://staging.example.com';
role = 'guest';
static get(): Config {
if (!Config.instance) Config.instance = new Config();
return Config.instance;
}
}
// admin.spec.ts
test('адмін бачить панель', async ({ page }) => {
Config.get().role = 'admin'; // мутуємо глобальний стан
await page.goto(Config.get().baseURL);
// ...
});
// guest.spec.ts — запускається паралельно в іншому воркері
test('гість не бачить панелі', async ({ page }) => {
await page.goto(Config.get().baseURL);
// Config.get().role тут може бути вже 'admin' — залежно від таймінгу
});
Симптом упізнаваний: обидва тести зелені поодинці, а в повному наборі під паралелізмом guest.spec.ts іноді червоніє, бо admin.spec.ts перезаписав спільний role. Падіння прив'язане до порядку й кількості воркерів, стабільно не відтворюється — це не баг застосунку, це дизайн тестового коду.
Правильна заміна — dependency injection через фікстуру. Конфіг живе один екземпляр на воркер, приходить у тест явно через сигнатуру, і жоден тест не може мовчки перезаписати чужий стан:
import { test as base } from '@playwright/test';
type WorkerFixtures = { appConfig: { baseURL: string } };
export const test = base.extend<{}, WorkerFixtures>({
appConfig: [async ({}, use) => {
await use(loadConfig()); // один екземпляр на воркер, без глобальної змінної
}, { scope: 'worker' }],
});
test('адмін бачить панель', async ({ page, appConfig }) => {
await page.goto(appConfig.baseURL); // залежність видно в сигнатурі
// роль тепер приходить через свою фікстуру/сторедж, а не через мутацію глобалки
});
Що дивитися і чому:
- Область життя контролює ранер, а не глобальна змінна.
scope: 'worker'дає той самий «один екземпляр на процес», що й Singleton, але без глобального доступу — воркери не б'ються за спільний об'єкт. - Залежність стала видимою. Раніше з сигнатури тесту не було зрозуміло, звідки
baseURL; теперappConfigстоїть у параметрах — читаєш тест і одразу бачиш, на що він спирається. - Read-only рятує не завжди. Якби конфіг лише читали й ніколи не мутували, Singleton був би терпимий. Біль створює саме мутація спільного стану — тому лікуємо не «прибрати конфіг», а «прибрати мутабельний глобальний доступ».
- Не міняй порядок тестів, щоб «полагодити». Якщо перестановка тестів робить набір зеленим — це не фікс, а маскування протікання стану; корінь усе одно в спільному Singleton.
Кейс 3. «Виглядає як Screenplay, а насправді POM»
Карго-культ найлегше побачити на Screenplay: команда прочитала про актори й задачі, перейменувала методи сторінки на Tasks — і вважає, що застосувала патерн. Ось підробка:
// LoginTask.ts — НЕ Screenplay, хоч і зветься Task
class LoginTask {
constructor(private page: Page) {}
async login(email: string, password: string) {
await this.page.locator('#email').fill(email); // лізе прямо в локатори
await this.page.locator('#password').fill(password);
await this.page.locator('#submit').click();
}
}
Ані актора, ані здібностей, ані композиції — це звичайний Page Object, якому змінили вивіску. Справжній Screenplay будується з дрібних інтеракцій навколо актора, і задача складається з них, а не звертається до локаторів напряму:
// справжній Screenplay (стиль Serenity/JS)
const LogIn = {
as: (email: string, password: string) =>
Task.where(`#actor логіниться як ${email}`,
Navigate.to('/login'),
Enter.theValue(email).into(LoginForm.email),
Enter.theValue(password).into(LoginForm.password),
Click.on(LoginForm.submit),
),
};
await actor.attemptsTo(LogIn.as('qa@example.com', 'secret'));
Що дивитися і чому:
- Назва в коді нічого не гарантує. Клас зі словом
Taskне робить проєкт Screenplay; дивитися треба на структуру — чи є актор, здібності й композиція з інтеракцій. - Ознака справжнього патерна — перевикористання дрібниць.
Enter,Click,Navigateзбираються в задачуLogIn, аLogIn— у більшу задачу; у підробці перевикористовувати нічого, бо все злите в один метод. - Підробка не дає вигоди, але бере ціну. Карго-культ додає церемоній і назв, не даючи ані ролей, ані строгого SOLID, ані нижчого ризику god object — тобто гірше за чесний POM, який хоча б не прикидається.
- Висновок для рев'ю коду. Якщо в PR з'явилися «Tasks», що напряму смикають локатори, це сигнал спитати: навіщо тут Screenplay і яку саме біль він знімає? Часто чесна відповідь — залишити звичайний Page Object.
Патерн як поняття
- Можу пояснити, що патерн — це названий рецепт рішення в контексті (не бібліотека й не імпортований код), а головна його цінність — спільна мова команди.
- Розрізняю три групи GoF: породжувальні (Builder, Factory, Singleton), структурні (Facade), поведінкові.
- Пам'ятаю, що Screenplay у каталозі GoF немає — він народився в тест-автоматизації.
Screenplay проти Page Object
- Знаю різницю в центрі моделі: POM — сторінка, Screenplay — актор із намірами.
- Можу назвати п'ять понять Screenplay: актор, здібності, задачі, інтеракції, питання.
- Можу пояснити, чому Screenplay строгіше тягне за SOLID і має нижчий ризик god object.
- Знаю тредоф: POM дешевший на вході, Screenplay виправданий на великих довговічних сюїтах із ролями.
Builder і Factory
- Можу пояснити, навіщо test data builder з fluent-інтерфейсом замість конструктора на п'ятнадцять полів.
- Знаю, що білдер ховає розумні дефолти в одному місці, тож нове поле правиться в білдері, а не в сотнях тестів.
- Знаю суть Factory: інкапсулювати рішення, який об'єкт створити, за одну дію.
- Тримаю в голові маркер Builder vs Factory: багато опціональних полів покроково — Builder; вибір однієї реалізації — Factory.
Facade
- Розумію, що фасад — простий інтерфейс поверх складної підсистеми (один метод замість п'яти класів).
- Знаю межу фасаду: не перетворитися на god object, що тягне пів застосунку в один клас.
Singleton і глобальний стан
- Можу пояснити, чому глобальний мутабельний конфіг ламає ізоляцію й дає падіння, залежні від порядку.
- Розумію, чому Singleton-драйвер несумісний із паралельним запуском (воркери б'ються за спільний стан).
- Розрізняю read-only незмінний конфіг (терпимо) і мутабельний глобальний (генератор флаку).
- Можу пояснити заміну на dependency injection через worker-scoped фікстуру в Playwright.
Патерн заради патерну
- Розумію принцип «спершу біль — потім патерн» і правило трьох.
- Можу назвати ознаки overengineering: абстракція з однією реалізацією, фабрика поверх одного класу, шар «на майбутнє».
- Можу назвати ціну кожного патерна, а не лише його користь — це те, що перевіряє інтерв'юер.
Що таке патерн проєктування в найточнішому сенсі?
Питання
Що таке патерн проєктування (design pattern)?