SOLID у тест-фреймворку
Зміст
SOLID випливає на співбесіді AQA з двох боків. Перший — прямий: інтерв'юер із бекенд-фоном просить «розкажіть про SOLID», і кандидат переказує п'ять означень з блогу, не давши жодного прикладу зі свого фреймворку. Другий — прихований, у щоденній роботі: тестовий проєкт із півсотні тестів починає гнити — на тисячу рядків, BaseTest на чотири рівні, один клас-хелпер, який чіпаєш заради однієї фічі й ламаєш десять інших. SOLID — це п'ять правил Роберта Мартіна (Robert C. Martin) про те, як розкласти код так, щоб зміна в одному місці не розходилася на весь проєкт. У тестах ця тема гостра особливо, бо тестовий код міняється так само часто, як продукт, який він перевіряє.
Ця глава спирається на дві попередні: механізми, якими оперує SOLID (інкапсуляція, поліморфізм, успадкування), розібрані в главі ООП на тестовому коді, а компроміс «композиція проти успадкування» — у главі Принципи дизайну тестового коду. Тут — систематичний погляд: п'ять принципів на прикладах автотестів і, що важливіше для мідла, чесна межа, за якою SOLID у тестах перетворюється на самоціль і шкодить читабельності.
SOLID коротко: п'ять способів пережити зміну
SOLID — акронім із п'яти принципів об'єктно-орієнтованого дизайну: самі принципи сформулював Роберт Мартін у роботі 2000 року, а в запам'ятовуваний акронім їх склав Майкл Фізерс (Michael Feathers) близько 2004-го. Спільна ідея всіх п'яти одна: зменшити зчеплення (coupling) між частинами коду так, щоб зміна лишалася локальною.
| Літера | Принцип | Одне речення |
|---|---|---|
| S | Single Responsibility | У класу — одна причина змінюватися |
| O | Open-Closed | Відкритий для розширення, закритий для змін |
| L | Liskov Substitution | Нащадок підставляється замість батька, не ламаючи виклик |
| I | Interface Segregation | Клієнт не залежить від методів, яких не використовує |
| D | Dependency Inversion | Залежимо від абстракції, а не від конкретної реалізації |
Важлива рамка на весь текст: SOLID писали для продуктового коду — довговічних модулів, які використовують багато місць. У тестах він застосовується з поправкою (докладно — в останньому розділі): найбільше він потрібен для інфраструктури тестів (, клієнти, ), а не для тіла окремого тесту, яке має лишатися пласким і читатися згори вниз.
S — єдина відповідальність (Single Responsibility Principle, SRP)
Формулювання Мартіна: у модуля має бути одна причина змінюватися — він відповідальний перед одним «актором». У тестах це найчастіше порушує god object — page object, який робить усе одразу.
// Порушення SRP: три різні причини змінюватися в одному класі
class LoginPage {
async login(user: User): Promise<void> { /* заповнити форму, клацнути */ }
async assertLoggedIn(): Promise<void> {
await expect(this.page).toHaveURL('/dashboard'); // причина 1: політика перевірок
}
async createUser(u: User): Promise<void> {
await this.api.post('/users', u); // причина 2: підготовка даних
}
async readUsersFromCsv(path: string): Promise<User[]> { /* ... */ } // причина 3: I/O
}
Цей клас змінюватимуть з трьох незалежних приводів: редизайн форми логіну, зміна того, що ми стверджуємо про успішний вхід, і зміна API створення юзерів. Три причини в одному файлі означають, що правка заради однієї зачепити дві інші, а конфлікти в git гарантовані. Розкладаємо за відповідальностями:
- Page object лише взаємодіє.
LoginPageзаповнює форму й клацає — і все. — його єдине джерело істини (див. Локатори). - Перевірки живуть у тесті.
expect— у тілі тесту, а не в page object, бо намір перевірки має бути видно локально (див. Анатомія автотесту). - Підготовка даних — у фабриці чи фікстурі. Створення юзера — це стратегія тест-даних, окрема відповідальність (див. Тест-дані).
SRP у тестах — найкорисніший із п'яти принципів, бо він прямо б'є по головному запаху автоматизації: god page object, у якому змішані локатори, дії, та .
O — відкритість/закритість (Open-Closed Principle, OCP)
Принцип: сутність має бути відкрита для розширення, але закрита для модифікації — нову поведінку додають, не переписуючи наявний перевірений код. У тестах найпрактичніший прояв OCP — це параметризація: додати новий випадок, не чіпаючи тіло тесту.
Антипатерн — розгалуження, яке доводиться редагувати щоразу:
// Порушення OCP: новий 'viewer' змушує лізти в цю функцію й додавати гілку
function permissionsFor(role: string): Permissions {
if (role === 'admin') return { canDelete: true, canEdit: true };
if (role === 'editor') return { canDelete: false, canEdit: true };
throw new Error(`unknown role: ${role}`);
}
Кожна нова роль — це правка робочого коду й ризик зачепити наявні гілки. Виносимо варіативність у дані, а логіку перевірки лишаємо незмінною:
// OCP: розширюємо матрицю даними, тіло тесту не чіпаємо
const roleMatrix = {
admin: { canDelete: true, canEdit: true },
editor: { canDelete: false, canEdit: true },
viewer: { canDelete: false, canEdit: false }, // додали рядок — код перевірки закритий для змін
} as const;
for (const [role, perms] of Object.entries(roleMatrix)) {
test(`${role}: права доступу до документа`, async ({ page }) => {
// один сценарій обслуговує всі ролі; нова роль не вимагає нового коду
});
}
Додати роль — це дописати рядок у дані, а не редагувати сценарій. Те саме працює для матриці «браузер × середовище» в конфігурації . Але чесно: OCP у тестах — це переважно параметризація й таблиці даних, а не плагінна архітектура; будувати формальний механізм розширення заради трьох випадків — це вже YAGNI (див. розділ нижче).
L — підстановка Лісков (Liskov Substitution Principle, LSP)
Принцип названо на честь Барбари Лісков (Barbara Liskov): об'єкт нащадка має підставлятися всюди, де очікують батька, не ламаючи того, хто його викликає. Порушення LSP — це коли нащадок формально успадковує тип, але зраджує його контракт: інша сигнатура, несподіваний виняток, інша семантика.
У тестах це вилазить у неглибоких на вигляд ієрархіях page object:
// Батько обіцяє: open() без аргументів веде на сторінку
abstract class BasePage {
abstract open(): Promise<void>;
}
class DashboardPage extends BasePage {
async open(): Promise<void> { await this.page.goto('/dashboard'); }
}
class ProfilePage extends BasePage {
// Порушення LSP: вимагає userId, якого немає в контракті батька
async open(userId: string): Promise<void> { await this.page.goto(`/users/${userId}`); }
}
Поки код працює з ProfilePage напряму — усе добре. Але щойно з'явиться загальний хелпер, який приймає будь-яку BasePage і викликає open() (наприклад, «прогрій усі сторінки перед сюїтою»), ProfilePage зламає його: open() без userId поведеться не так, як обіцяв контракт. Це не баг конкретного методу — це неправильно обрана ієрархія.
LSP дає систематичну відповідь на питання «чому глибокі ієрархії болять» з попередніх глав: якщо нащадок не підставляється замість батька чисто, значить, зв'язок насправді не «є» (is-a), а «має» (has-a), і його треба виражати композицією, а не extends. Найпростіший спосіб не порушити LSP у тестах — не будувати ієрархію там, де достатньо композиції компонентів (див. Page Object).
I — розділення інтерфейсів (Interface Segregation Principle, ISP)
Принцип: клієнт не повинен залежати від методів, яких не використовує. Замість одного «товстого» інтерфейсу — кілька вузьких і сфокусованих. У тестах товстий інтерфейс — це god-хелпер, який уміє все на світі.
Виноска для тих, хто готується до CTAL-TAE: назву interface segregation тут ужито за автором акроніма — у Роберта Мартіна це «ISP) The Interface Segregation Principle». Силабус ISTQB CTAL-TAE v2.0 розшифровує цю літеру інакше — дослівно, §3.1.5, с. 28: «It is an acronym of single responsibility, open-closed, Liskov substitution, interface substitution and dependency inversion». Тобто в силабусі стоїть interface substitution, і на іспиті ви побачите саме це формулювання. Різниця не косметична: «підстановка» вже зайнята сусідньою літерою L (Liskov substitution), а сегрегація описує інше — розділення товстого контракту на вузькі. Галузева норма й сам автор принципу вживають segregation, тож глава пише так само, але про рядок силабуса треба знати.
// Порушення ISP: смоук-тесту потрібен лише логін, а він тягне весь контракт
interface TestHelper {
loginViaUi(): Promise<void>;
loginViaApi(): Promise<void>;
seedOrders(n: number): Promise<void>;
clearInbox(): Promise<void>;
chargeCard(amount: number): Promise<void>;
// ...ще 30 методів
}
Ціна такого інтерфейсу видно саме в тестах. По-перше, тест, якому треба лише залогінитись, змушений знати про 35 методів. По-друге — і це болючіше — щоб замокати цей TestHelper у якомусь ізольованому тесті, доведеться застабити всі 35 методів, хоча використовуються два. Розбиваємо на вузькі здатності:
// ISP: сфокусовані контракти — тест залежить рівно від того, що використовує
interface Auth { loginAs(role: Role): Promise<void>; }
interface OrderApi { seed(n: number): Promise<Order[]>; }
interface Mailbox { latest(): Promise<Email>; }
Це рідна модель фікстур: краще кілька дрібних фікстур, кожну з яких тест підключає за потреби, ніж одна god-фікстура з усім усередині. На тому самому принципі стоять «здатності» (abilities) у патерні Screenplay — вони навмисно дрібні (див. Патерни в автоматизації).
D — інверсія залежностей (Dependency Inversion Principle, DIP)
Принцип: високорівневий модуль не має залежати від низькорівневого — обидва залежать від абстракції; деталі залежать від абстракції, а не навпаки. У тестах високий рівень — це сам тест (політика: що перевірити), низький — конкретика (як саме: HTTP-бібліотека, механізм логіну, реальний чи -сервіс). DIP каже: тест не має знати цю конкретику.
І тут головне для AQA: фікстури Playwright працюють як (dependency injection, DI) — механізм, яким реалізують DIP. Тест оголошує в сигнатурі, що йому потрібна абстракція, а фікстура вирішує, яку реалізацію підкласти. Одразу застереження: це наше прочитання, а не слова доки — сторінка «Test fixtures» говорить про ізоляцію, підключення на вимогу й , а терміна dependency injection не вживає взагалі. Прямо цей термін уживає CodeceptJS: там є окремий розділ «Dependency Injection» і поле include як реєстр інжектованих обʼєктів.
// Абстракція, від якої залежить тест (з глави про ООП)
interface LoginFlow { loginAs(role: Role): Promise<void>; }
// Фікстура вирішує, ЯКУ реалізацію дати; тест цього не знає
export const test = base.extend<{ loginFlow: LoginFlow }>({
loginFlow: async ({ page, request, context }, use) => {
const flow = process.env.LOGIN === 'ui'
? new UiLogin(page) // повільно, перевіряє саму форму
: new ApiLogin(request, context); // швидко, як підготовка стану
await use(flow);
},
});
test('адмін бачить панель керування', async ({ loginFlow, page }) => {
await loginFlow.loginAs('admin'); // залежність від контракту, не від реалізації
await expect(page.getByRole('heading', { name: 'Admin' })).toBeVisible();
});
Тест залежить від LoginFlow, а UiLogin і ApiLogin — теж від LoginFlow. Стрілка залежності перевернута: деталі вказують на абстракцію, а не тест на деталі. Той самий прийом дозволяє підмінити реальний платіжний сервіс його мок-дублером, не змінивши жодного тесту (див. Тестові дублери).
Ключове розведення для співбесіди: DIP — це принцип (залежати від абстракції), а DI через фікстури — механізм, яким його досягають. У сучасних ранерах DIP дістається майже даром: якщо ви користуєтесь фікстурами замість того, щоб робити new ApiClient(...) всередині кожного тесту, — ви вже застосовуєте DIP, навіть не називаючи його так (механіка фікстур — Тестовий стек зсередини).
Де SOLID у тестах — overengineering
А тепер найважливіше для мідла: розуміти не лише як застосувати SOLID, а й де він шкодить. SOLID писали для продуктового коду — модулів, які живуть роками, використовуються десятками місць і мусять пережити зміну вимог. Тестовий код інший: окремий тест — це передусім документація, жива специфікація поведінки, яку читають згори вниз (принцип DAMP із глави про принципи дизайну). Абстракція, додана заради «чистого SOLID», але за рахунок читабельності тесту, працює проти головної мети тесту.
Практична межа проходить між інфраструктурою й тілом тесту:
| Принцип | Варте застосування в тестах | Зазвичай overengineering |
|---|---|---|
| SRP | Розвести локатори / дії / асерти / сідінг | Дробити page object на класи по одному методу |
| OCP | Параметризація, таблиці даних, матриці конфігу | Плагінна архітектура на 20 тестів |
| LSP | Не форсувати extends там, де не «є» | Будувати LSP-коректну ієрархію замість композиції |
| ISP | Дрібні сфокусовані фікстури й клієнти | Інтерфейс під кожен клас «про всяк випадок» |
| DIP | Фікстури/DI для клієнтів і логіну | Шар абстракцій «щоб легко переїхати з Playwright» |
Типова траєкторія перебору: команда бачить, що фікстури — це DIP, і починає ховати будь-яку залежність за інтерфейсом; кожен page object отримує інтерфейс, якому відповідає одна реалізація; з'являється абстрактний шар над Playwright «щоб легко змінити інструмент», якого ніхто ніколи не змінить. Це не SOLID — це його карго-культ. Інтерфейс з єдиною реалізацією не додає гнучкості, а лише зайвий файл, у який стрибати на Cmd+click.
Здорове правило: застосовуйте SOLID до того тестового коду, який реально має багато викликачів — page object, API-клієнти, фікстури. Це справжня інфраструктура, і там принципи окупаються. Не застосовуйте його до окремого тесту — тест лишається пласким, явним, DAMP, навіть ціною повторів між сусідніми тестами. Якщо, щоб зрозуміти один тест, доводиться відкрити три інтерфейси й дві фабрики, SOLID тут переможений YAGNI, і рефакторинг мав би прибирати абстракції, а не додавати.
Окремо про джерела теми: спеціального розділу в силабусі ISTQB CTFL 4.0 SOLID не має — це загальноінженерна, а не тест-аналітична тема. У контексті автоматизації її читають разом із главами про ООП і принципи дизайну тестового коду, а канонічну опору з боку тестування дає CTAL-TAE.
Типові помилки
- Виглядає як SRP, а насправді дроблення заради дроблення. Розбити page object на десять класів по одному методу — це не єдина відповідальність, а фрагментація, у якій сторінку неможливо зібрати думкою. SRP — про різні причини змінюватися, а не про кількість методів у класі.
- Виглядає як DIP, а насправді зайвий шар. Інтерфейс, у якого рівно одна реалізація і жодного плану на другу, не інвертує залежність, а лише додає файл. DIP окупається там, де реалізацій справді більше однієї (UI/API-логін, реальний сервіс/мок).
- Виглядає як OCP, а насправді передчасна платформа. Плагінний механізм «щоб потім легко додавати перевірки» на проєкті з двадцятьма тестами — це YAGNI. Чесний OCP у тестах — це параметризація, а не фреймворк усередині фреймворку.
- Виглядає як гарна ієрархія, а насправді порушення LSP.
ProfilePage.open(id)замістьopen()— нащадок, який не підставляється замість батька. Симптом того, що зв'язок насправді «має», і його треба виражати композицією, а не успадкуванням. - Виглядає як ISP, а насправді god-фікстура. Одна фікстура, що віддає об'єкт із півсотнею методів, змушує кожен тест і кожен мок знати про все. Дрібні сфокусовані фікстури й клієнти читаються й мокаються локально.
Підсумок
- SOLID — п'ять правил про зменшення зчеплення, щоб зміна лишалася локальною; у тестах найцінніші SRP (проти god page object) і DIP (через фікстури — дістається майже даром).
- LSP дає систематичну причину, чому глибокі ієрархії болять: якщо нащадок не підставляється замість батька чисто, зв'язок «має», а не «є», — і його місце в композиції, а не в
extends. - OCP у тестах — це параметризація й таблиці даних (розширюємо даними, не чіпаючи код), а не плагінна архітектура; ISP — дрібні сфокусовані фікстури й клієнти замість god-хелпера.
- Межа з overengineering проходить між інфраструктурою й тілом тесту: SOLID застосовують до page object, клієнтів і фікстур (багато викликачів), а окремий тест лишають пласким і DAMP.
- Інтерфейс з єдиною реалізацією, абстракція над Playwright «на майбутнє», ієрархія заради LSP там, де досить композиції, — це карго-культ SOLID, а не SOLID.
Можливі питання
- «Розкажіть про SOLID» — базовий фільтр, зокрема від бекенд-інтерв'юера. Слабка відповідь — п'ять означень. Сильна — кожна літера + приклад із вашого тестового коду (SRP: розвести дії й асерти в page object; DIP: фікстури як DI). Інтерв'юер перевіряє, чи ви цим користувались.
- «Наведіть приклад SRP / DIP у своєму фреймворку» — сигнал зрілості. Готові приклади: SRP — page object лише взаємодіє, асерти в тесті, сідінг у фабриці; DIP — тест залежить від
LoginFlow-фікстури, а не відnew ApiClient(). - «Що таке LSP і до чого воно в page object?» — мідл-рівень. Чекають на зв'язок: якщо нащадок ламає контракт батька (інша сигнатура
open()), це знак, що потрібна композиція, а не успадкування. - «Де фікстури Playwright — це SOLID?» — перевіряють, чи бачите ви DIP/DI за інструментом. Відповідь: фікстура підставляє тесту готову залежність, тож тест не знає конкретної реалізації — це і є DIP на практиці. Корисно додати, що сама дока Playwright слова «dependency injection» не вживає (на відміну від CodeceptJS) — це наша інтерпретація механізму.
- «Коли SOLID у тестах — це перебір?» — найсильніше питання, відрізняє мідла від того, хто вивчив акронім. Очікують: тести — це документація, надлишкові абстракції вбивають читабельність; SOLID — для інфраструктури (page object, клієнти, фікстури), тіло тесту лишається пласким; інтерфейс з однією реалізацією — запах.
Джерела
SOLID коротко: п'ять способів пережити зміну
- Wikipedia — SOLID — акронім пʼяти принципів, покликаних зробити код зрозумілішим, гнучкішим і легшим у підтримці; там же факт, який часто плутають: акронім склав Michael Feathers (~2004), тобто автор принципів і автор акроніма — різні люди (
trust: secondary, dec-0710). - Robert C. Martin — Solid Relevance — пʼять однорядкових формулювань від автора принципів і різні модальності, які не є стилістикою:
shouldв OCP і DIP,must not— у LSP. - ISTQB® CTAL-TAE Syllabus v2.0 — §3.1.5: SOLID віднесено до принципів, що покращують читабельність, підтримуваність і масштабованість саме тестового коду.
S — єдина відповідальність (Single Responsibility Principle, SRP)
- Robert C. Martin — The Single Responsibility Principle — автор сам називає класичне «one and only one reason to change» недостатнім («What defines a reason to change?») і доводить формулювання до людей: зміни модуля мають походити від однієї вузько визначеної бізнес-функції.
- Robert C. Martin — Solid Relevance — робоче формулювання того самого принципу: «Gather together the things that change for the same reasons. Separate things that change for different reasons».
- Wikipedia — SOLID — звʼязок із тестуванням названо прямо: тестованість — наслідок SRP, бо класи з одним фокусом легше покривати (
trust: secondary, dec-0710).
O — відкритість/закритість (Open-Closed Principle, OCP)
- Robert C. Martin — Solid Relevance — формулювання й модальність першоджерела: «A Module should be open for extension but closed for modification».
- ISTQB® CTAL-TAE Syllabus v2.0 — §3.1.5: розшифровка акроніма в канонічному для розділу джерелі, включно з open-closed.
L — підстановка Лісков (Liskov Substitution Principle, LSP)
- Robert C. Martin — Solid Relevance — авторське формулювання з найсильнішою модальністю («A program that uses an interface must not be confused by an implementation of that interface») і визнання поширеної помилки: «People (including me) have made the mistake that this is about inheritance. It is not. It is about sub-typing»; симптом порушення — розмноження
if/switchу клієнтському коді. - Wikipedia — Liskov substitution principle — чому порушення не ловить компілятор: відношення семантичне, а не синтаксичне, тож виявляється лише на виконанні (
trust: secondary, dec-0710).
I — розділення інтерфейсів (Interface Segregation Principle, ISP)
- Robert C. Martin — Solid Relevance — «Keep interfaces small so that users donʼt end up depending on things they donʼt need»; назва літери в автора акроніма — «ISP) The Interface Segregation Principle».
- ISTQB® CTAL-TAE Syllabus v2.0 — §3.1.5, с. 28: як цю саму літеру розшифровує канонічне джерело розділу — «interface substitution».
- Playwright — Test fixtures — модель дрібних фікстур на вимогу: оснастку піднімають лише під те, що тест справді просить.
D — інверсія залежностей (Dependency Inversion Principle, DIP)
- Robert C. Martin — Solid Relevance — «Depend in the direction of abstraction. High level modules should not depend upon low level details».
- CodeceptJS — Page Objects — де термін «впровадження залежностей» ужито прямо: «All objects described here are injected via Dependency Injection», а
includeє реєстром інжектованих обʼєктів. - Playwright — Test fixtures — межа: та сама механіка описана іншими словами, словосполучення
dependency injectionна сторінці немає жодного разу — читання фікстур як DI наше. - Wikipedia — SOLID — слабке зчеплення від DIP робить код гнучкішим і легшим у тестуванні (
trust: secondary, dec-0710).
Де SOLID у тестах — overengineering
- ISTQB® CTAL-TAE Syllabus v2.0 — §3.1.5: канон рекомендує вживати патерни й принципи там, де вони корисні й доречні, а не всюди — це і є межа застосування SOLID у тестах.
- Playwright — Best Practices — той самий обмежувач з боку інструмента: трохи дублювання в простих тестах дозволено, якщо це робить їх зрозумілішими й легшими в підтримці.
Що таке SOLID і чому цю тему питають саме в AQA?
SOLID — набір із п'яти принципів об'єктно-орієнтованого дизайну, які сформулював Роберт Мартін, а в акронім склав Майкл Фізерс. Спільна мета всіх п'яти — послабити зчеплення (coupling) між частинами коду, щоб правка в одному місці не розповзалася на весь проєкт. В автоматизації тема виринає двома шляхами: прямо (інтерв'юер із бекенд-фоном просить «розкажіть про SOLID») і приховано — коли тестовий проєкт розростається, з'являється на тисячу рядків, а один хелпер чіпаєш заради дрібниці й ламаєш десяток тестів. Оскільки тестовий код правлять не рідше за продукт, який він перевіряє, локалізація змін тут особливо цінна. Слабка відповідь — переказати п'ять означень з блогу; сильна — до кожної літери дати приклад із власного фреймворку.
Що таке принцип єдиної відповідальності (SRP) і як він порушується в page object?
SRP каже, що в модуля має бути лише одна причина змінюватися — він служить одному «акторові». У тестах його найчастіше вбиває god , який одночасно клацає по формі, стверджує результат, створює тест-дані через API й читає CSV. Такий клас доведеться правити з трьох незалежних приводів: редизайн верстки, зміна політики перевірок і зміна контракту створення юзерів. Три причини в одному файлі означають, що правка заради однієї зачіпає дві інші, а git-конфлікти стають нормою. Лікування — розвести відповідальності: page object лише взаємодіє з UI, живуть у тілі тесту, підготовка даних іде у фабрику чи . SRP у тестах — найкорисніший із п'яти принципів, бо б'є прямо по головному запаху автоматизації.
Чому асерти не варто ховати всередину page object?
Бо намір перевірки має бути видно локально, у тілі тесту, який читають згори вниз як специфікацію поведінки. Коли expect захований у методі page object, читач бачить виклик на кшталт assertLoggedIn(), але не бачить, що саме цей метод стверджує — доводиться стрибати у файл сторінки. Це ще й змішує дві відповідальності в одному класі: взаємодію з UI і політику перевірок, які змінюються з різних причин. Page object повертає стан або , а рішення «що вважати успіхом» ухвалює тест. Виняток — дрібні технічні очікування видимості всередині дій, але доменні перевірки результату лишаються у сценарії.
Що означає принцип відкритості/закритості (OCP) у контексті автотестів?
OCP вимагає, щоб сутність була відкрита для розширення, але закрита для модифікації: нову поведінку додають, не переписуючи наявний перевірений код. У тестах його найпрактичніший прояв — параметризація: новий випадок додається як рядок даних, а тіло тесту лишається незмінним. Антипатерн — функція з ланцюжком if role === ..., у яку доводиться лізти щоразу, коли з'являється нова роль, ризикуючи зачепити наявні гілки. Виносимо варіативність у дані (матрицю ролей, таблицю прав), а логіку перевірки лишаємо однією. Але чесно: OCP у тестах — це переважно таблиці даних і матриці конфігурації, а не плагінна архітектура; будувати механізм розширення заради трьох випадків — уже YAGNI.
Що таке принцип підстановки Лісков (LSP) і до чого він у page object?
LSP каже: об'єкт нащадка має підставлятися всюди, де очікують батька, не ламаючи того, хто його викликає. Порушення — коли нащадок формально успадковує тип, але зраджує контракт: інша сигнатура методу, несподіваний виняток, інша семантика. Класичний приклад у тестах — BasePage обіцяє open() без аргументів, а ProfilePage перевизначає його як open(userId). Поки з ProfilePage працюють напряму — усе тихо, але щойно з'явиться загальний хелпер, який приймає будь-яку BasePage і викликає open(), профіль його зламає. Це не баг методу, а неправильно обрана ієрархія: якщо нащадок не підставляється чисто, зв'язок насправді не is-a, а has-a, і його місце в композиції, а не в extends.
Як під час код-рев'ю розпізнати, що ієрархія page object порушує LSP?
Головний симптом — нащадок, який змінює сигнатуру успадкованого методу або звужує його придатність: вимагає зайвий аргумент, кидає виняток там, де батько успішно виконувався, повертає інший тип чи вужчий діапазон значень. Другий сигнал — виклик, який мусить перевіряти конкретний підтип (if (page instanceof ProfilePage)), щоб коректно скористатися нібито спільним інтерфейсом: якщо код мусить знати реальний клас, підстановка вже зламана. Третій — коментар «цей метод у нащадку не використовуй» або порожня реалізація-заглушка. Усі три означають, що успадкування натягнули на зв'язок, який ним не є. Практичний висновок: переведіть спільну частину в композицію компонентів, а не в базовий клас.
Що таке принцип розділення інтерфейсів (ISP) і як він проявляється у фікстурах?
ISP вимагає, щоб клієнт не залежав від методів, яких не використовує: замість одного «товстого» інтерфейсу — кілька вузьких і сфокусованих. У тестах товстий інтерфейс — це god-хелпер, який уміє логінитись через UI й API, сідити замовлення, чистити пошту й списувати гроші з картки. Ціна видно саме в автоматизації: -тесту, якому треба лише логін, доводиться знати про 35 методів, а щоб замокати такий хелпер в ізольованому тесті — застабити всі 35, хоча використовуються два. Розбиваємо на вузькі здатності: окремо Auth, окремо OrderApi, окремо Mailbox. Це рідна модель фікстур — кілька дрібних, кожну тест підключає за потреби, замість однієї god-фікстури; на тому самому стоять «здатності» (abilities) у Screenplay. Одна поправка, якщо готуєшся до CTAL-TAE: назва interface segregation тут — за автором акроніма («ISP) The Interface Segregation Principle»), а силабус ISTQB розшифровує цю літеру інакше — дослівно, §3.1.5: «It is an acronym of single responsibility, open-closed, Liskov substitution, interface substitution and dependency inversion». На іспиті стоїть interface substitution; галузева норма й сам автор принципу пишуть segregation.
Що таке принцип інверсії залежностей (DIP)?
DIP каже: високорівневий модуль не має залежати від низькорівневого — обидва залежать від абстракції, і деталі залежать від абстракції, а не навпаки. У тестах високий рівень — це сам тест, що вирішує що перевірити; низький — конкретика як саме: яка HTTP-бібліотека, реальний сервіс чи , логін через форму чи через API. DIP вимагає, щоб тест цієї конкретики не знав: він оголошує потребу в абстракції (наприклад, інтерфейсі LoginFlow), а хтось інший вирішує, яку реалізацію підкласти. Практичний виграш — ту саму перевірку можна прогнати проти реального й проти мок-сервісу, не змінивши жодного рядка тесту. Стрілка залежності перевернута: деталі вказують на абстракцію.
У чому різниця між DIP і DI?
Ці два поняття плутають постійно. DIP (dependency inversion principle) — це принцип: залежати від абстракції, а не від конкретної реалізації. DI (dependency injection) — це механізм, яким принцип досягають: залежність не створюють усередині об'єкта через new, а передають ззовні. Одне — мета, друге — спосіб її досягти. В автоматизації найважливіший приклад: фікстури Playwright працюють як DI, тож коли тест оголошує залежність у сигнатурі, а фікстура вирішує, яку реалізацію дати, ви застосовуєте DIP через механізм DI. Термінологічна чесність: сама дока Playwright слова «dependency injection» не вживає — прямо його вживає CodeceptJS, а прочитання фікстур як DI наше. Можна досягти DIP і без DI (передати залежність аргументом конструктора вручну), тому змішувати ці поняття — ознака поверхового розуміння.
Чому кажуть, що фікстури Playwright дають DIP «майже даром»?
Бо механіка інверсії вже вбудована в : тест оголошує в сигнатурі, що йому потрібен, скажімо, loginFlow, а фікстура сама вирішує, підкласти UiLogin чи ApiLogin залежно від оточення. Тестові тіла не створюють залежності через new ApiClient(...), а отримують готові — ми читаємо це як , тобто DIP у дії (сама дока Playwright цим терміном не користується). Тому якщо команда користується фікстурами замість ручного інстанціювання клієнтів у кожному тесті, вона вже застосовує DIP, навіть не називаючи його так. Кандидат, який це бачить, показує, що розуміє принцип за інструментом, а не завчив акронім. Умова одна: залежати треба від контракту (інтерфейсу), а не від конкретного класу — інакше це просто передача об'єкта, без інверсії.
Який із п'яти принципів найкорисніший у тестах і чому?
SRP і DIP. SRP — тому що прямо цілить у головний запах автоматизації: god page object, у якому змішані локатори, дії, асерти та ; розвести ці відповідальності окуповується майже завжди. DIP — тому що в сучасних ранерах він дістається майже безкоштовно через фікстури: варто відмовитися від new усередині тестів на користь — і залежність від абстракції вже є. Ці два дають найбільший приріст супроводжуваності за найменших зусиль. Решта корисні ситуативно: OCP зводиться до параметризації, ISP — до дрібних фікстур, а LSP найчастіше працює як застереження не будувати ієрархію там, де досить композиції.
Коли SOLID у тестах перетворюється на overengineering?
Коли абстракцію додають заради «чистого SOLID» ціною читабельності окремого тесту. Тест — передусім документація, жива специфікація, яку читають згори вниз (принцип DAMP), і зайвий шар індирекції працює проти цієї мети. Практична межа проходить між інфраструктурою й тілом тесту: page object, клієнти й фікстури мають багато викликачів — там принципи окупаються; окремий тест лишається пласким і явним, навіть ціною повторів між сусідніми тестами. Симптоми перебору: інтерфейс під кожен клас із єдиною реалізацією, абстрактний шар над Playwright «щоб легко переїхати», ієрархія заради LSP там, де досить композиції. Якщо, щоб зрозуміти один тест, доводиться відкрити три інтерфейси й дві фабрики, тут SOLID переможений YAGNI, і рефакторинг мав би прибирати абстракції, а не додавати. Це найсильніше питання — воно відрізняє мідла від того, хто просто вивчив акронім.
Чому інтерфейс з єдиною реалізацією — це запах, а не DIP?
Тому що інверсія залежності окуповується лише там, де реалізацій справді більше однієї або вони гарантовано з'являться: UI/API-логін, реальний сервіс проти мока. Інтерфейс, у якого рівно одна реалізація і жодного плану на другу, нічого не інвертує — він додає файл, у який доводиться стрибати на Cmd+click, не даючи взамін гнучкості. Це карго-культ: команда побачила, що фікстури — це DIP, і почала ховати будь-яку залежність за інтерфейсом. Правило простіше: заводьте абстракцію, коли маєте другу реалізацію під рукою або впевнено її передбачаєте, а не «про всяк випадок». Гнучкість, якою ніхто не скористається, — це не гнучкість, а вартість супроводу.
Де проходить межа застосування SOLID у тестовому проєкті?
Між інфраструктурою й тілом тесту. Інфраструктура — page object, API-клієнти, фікстури — має багато викликачів і живе довго, тож зміна вимог там реальна, і принципи локалізації змін окуповуються. Окреме тіло тесту має рівно одного «викликача» (сам ранер) і читається як документація, тож його лишають пласким, явним і DAMP навіть ціною дублювання між сусідніми тестами. Практично: SOLID застосовують до того тестового коду, який реально перевикористовують, і не застосовують до сценарію, де надлишкова абстракція вбиває читабельність. Це та сама думка, що й у продуктовому коді, але з поправкою: у тестах вага читабельності окремого сценарію вища, ніж у бібліотечному модулі, бо тест ще й документує поведінку.
Як пов'язані LSP і суперечка «композиція проти успадкування»?
LSP дає систематичну відповідь на питання, чому глибокі ієрархії болять. Успадкування виражає зв'язок is-a: нащадок є різновидом батька й підставляється замість нього всюди. Якщо ж нащадок не підставляється чисто — вимагає інших аргументів, порушує контракт, потребує перевірки типу у викликача — значить, зв'язок насправді has-a, і його треба виражати композицією: сторінка має компонент, а не є ним. Тобто порушення LSP — це діагностичний сигнал, що обрано неправильний інструмент моделювання. Найпростіший спосіб узагалі не порушувати LSP у тестах — не будувати ієрархію page object там, де достатньо зібрати сторінку з компонентів. Тому досвідчені команди тяжіють до пласких page object із композицією замість багаторівневих BasePage.
Три кейси, у яких SOLID перестає бути теорією зі співбесіди й вирішує, чи гниє тестовий проєкт: розбір god по SRP, логіну через як DIP, і код-рев'ю, де треба відрізнити здоровий принцип від його карго-культу. Скрізь — що дивитися і чому.
Кейс 1. Розбираємо god page object по SRP
LoginPage розрісся: він і клацає по формі, і стверджує результат, і створює юзера через API, і читає дані з CSV. Такий клас правлять з кількох незалежних приводів, тож правка заради однієї речі регулярно ламає сусідні.
// До: чотири відповідальності в одному класі
class LoginPage {
async login(user: User): Promise<void> { /* форма + клік */ }
async assertLoggedIn(): Promise<void> {
await expect(this.page).toHaveURL('/dashboard'); // політика перевірок
}
async createUser(u: User): Promise<void> {
await this.api.post('/users', u); // підготовка даних
}
async readUsersFromCsv(path: string): Promise<User[]> { /* I/O */ }
}
Розводимо за причинами змінюватися: сторінка лишає собі тільки взаємодію з UI, перевірка їде в тест, — у фікстуру/фабрику.
// Після: page object лише взаємодіє з формою
class LoginPage {
async login(user: User): Promise<void> {
await this.page.getByLabel('Email').fill(user.email);
await this.page.getByLabel('Password').fill(user.password);
await this.page.getByRole('button', { name: 'Sign in' }).click();
}
}
// Перевірка — у тілі тесту, намір видно локально
test('успішний вхід веде на дашборд', async ({ page, seededUser }) => {
const login = new LoginPage(page);
await login.login(seededUser);
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
Що дивитися і чому:
- Рахуйте причини змінюватися, а не методи. Редизайн форми, зміна того, що вважати успішним входом, і зміна контракту створення юзера — три незалежні приводи. Поки вони в одному файлі, правка одного зачепити два інші, а git-конфлікти гарантовані.
- Асерція в тесті, а не в сторінці. Коли
expectзахований уassertLoggedIn(), читач бачить назву методу, але не бачить, що саме стверджується, і мусить стрибати у файл. Тест має читатися згори вниз як специфікація. - Сідінг — окрема відповідальність. Створення юзера переїхало у фікстуру
seededUser; тепер його можна робити через API швидко й тими самими даними живити будь-який тест, не тягнучи API-логіку в кожну сторінку.
Кейс 2. DIP через фікстуру: один тест, дві реалізації логіну
Логін у тесті потрібен двома способами: через форму (коли перевіряємо саму автентифікацію) і через API (коли логін — лише підготовка стану, і хочеться швидко). DIP каже: тест не має знати, який саме варіант виконується, — він залежить від контракту, а рішення ухвалює фікстура.
// Абстракція, від якої залежить тест
interface LoginFlow {
loginAs(role: Role): Promise<void>;
}
// Дві реалізації того самого контракту
class UiLogin implements LoginFlow {
constructor(private page: Page) {}
async loginAs(role: Role): Promise<void> { /* повільно, через форму */ }
}
class ApiLogin implements LoginFlow {
constructor(private request: APIRequestContext, private context: BrowserContext) {}
async loginAs(role: Role): Promise<void> { /* швидко, ставить кукі напряму */ }
}
// Фікстура вирішує, ЯКУ реалізацію підкласти — тест цього не знає
export const test = base.extend<{ loginFlow: LoginFlow }>({
loginFlow: async ({ page, request, context }, use) => {
const flow = process.env.LOGIN === 'ui'
? new UiLogin(page)
: new ApiLogin(request, context);
await use(flow);
},
});
test('адмін бачить панель керування', async ({ loginFlow, page }) => {
await loginFlow.loginAs('admin'); // залежність від контракту, не від класу
await expect(page.getByRole('heading', { name: 'Admin' })).toBeVisible();
});
Що дивитися і чому:
- Стрілка залежності перевернута. І тест, і
UiLogin, іApiLoginвказують наLoginFlow. Тест не робитьnew ApiLogin(...)усередині — саме тому підмінити реалізацію можна змінною оточення, не торкаючись сценарію. - DIP — принцип, фікстура — механізм (DI).
base.extendподає залежність ззовні: ми читаємо це як dependency injection, яким досягається dependency inversion (сама дока Playwright терміна DI не вживає — прямо його вживає CodeceptJS). На співбесіді ці два поняття треба розводити явно. - Абстракція виправдана, бо реалізацій справді дві. Тут інтерфейс не «про всяк випадок»: є UI-логін і API-логін, і обидва живі. Це та межа, за якою
LoginFlowокупається; для одного-єдиного способу логіну інтерфейс був би зайвим файлом.
Кейс 3. Код-рев'ю: SOLID чи його карго-культ?
У pull request прилетів «рефакторинг по SOLID». Треба відрізнити принцип, що окупається, від абстракції заради галочки. Ось три зміни з одного PR і вердикт по кожній.
// Зміна А: інтерфейс під page object з єдиною реалізацією
interface ILoginPage { login(u: User): Promise<void>; }
class LoginPage implements ILoginPage { /* єдина реалізація в проєкті */ }
// Зміна Б: обгортка над Playwright «щоб легко змінити інструмент»
interface IBrowserDriver { click(sel: string): Promise<void>; /* +20 методів */ }
class PlaywrightDriver implements IBrowserDriver { /* єдина реалізація */ }
// Зміна В: god page object розбито — асерції поїхали в тест, сідінг у фікстуру
Вердикт по кожному пункті зводимо в :
| Зміна | Що це насправді | Вердикт |
|---|---|---|
А. ILoginPage з однією реалізацією | Зайвий файл, у який стрибати на Cmd+click; нічого не інвертує | Відхилити — не DIP, а карго-культ |
| Б. Абстракція над Playwright | Шар «щоб переїхати з інструмента», якого ніхто не змінить | Відхилити — YAGNI, чиста вартість супроводу |
| В. Розбиття god page object | Розведення відповідальностей за причинами змінюватися | Прийняти — це справжній SRP |
Що дивитися і чому:
- Інтерфейс з єдиною реалізацією не додає гнучкості. DIP окупається там, де реалізацій більше однієї (як
LoginFlowз кейсу 2) або вони гарантовано з'являться.ILoginPageпід один клас — це не інверсія, а зайва індирекція. - «Щоб легко змінити інструмент» — класичний YAGNI. Абстрактний шар над Playwright пишуть під міграцію, яка ніколи не станеться; натомість він розмазує просте API Playwright по власному діалекту й ускладнює дебаг.
- Розведення приймаємо без вагань. Це той випадок, коли SOLID б'є в ціль: page object отримав одну причину змінюватися, тест став читабельнішим, сідінг перевикористовується. Межа проста — принцип застосовуємо до інфраструктури з багатьма викликачами, а не заводимо абстракцію заради самої абстракції.
SOLID як рамка
- Можу назвати п'ять принципів (SRP, OCP, LSP, ISP, DIP) і одним реченням сказати, за що відповідає кожен.
- Розумію спільну мету всіх п'яти: послабити зчеплення, щоб зміна лишалася локальною.
- Знаю, що SOLID писали для продуктового коду, і вмію пояснити, з якою поправкою він переноситься на тести.
- Можу дати до кожної літери приклад із власного тестового фреймворку, а не переказати означення з блогу.
SRP і OCP
- Можу пояснити SRP через «одну причину змінюватися», а не через «один метод у класі».
- Розпізнаю god і знаю, як розвести , дії, та за відповідальностями.
- Розумію, чому асерти живуть у тілі тесту, а не всередині page object.
- Можу пояснити OCP на прикладі параметризації: нова роль — це рядок даних, а не правка сценарію, і знаю межу з передчасною плагінною архітектурою.
LSP і композиція
- Можу пояснити LSP через підстановку: нащадок стає замість батька, не ламаючи викликача.
- Розпізнаю порушення LSP: інша сигнатура методу, зайвий аргумент,
instanceof-перевірка у викликача. - Розумію, що порушення LSP — сигнал, що зв'язок насправді
has-a, а неis-a. - Можу пояснити, чому композиція компонентів у page object безпечніша за глибоку ієрархію
extends.
ISP і DIP
- Розпізнаю god-хелпер і знаю, чому вузькі сфокусовані інтерфейси легше і використовувати, і .
- Можу пояснити, чому дрібні кращі за одну god-фікстуру з півсотнею методів.
- Чітко розрізняю DIP (принцип: залежати від абстракції) і DI (механізм: передати залежність ззовні).
- Можу пояснити, чому фікстури Playwright — це DI, і чому вони дають DIP «майже даром», а також що DIP окупається лише за наявності другої реалізації.
Де SOLID шкодить
- Можу назвати межу застосування: інфраструктура (page object, клієнти, фікстури) — так, тіло окремого тесту — ні.
- Розумію, чому тест лишають пласким і DAMP навіть ціною повторів між сусідніми тестами.
- Розпізнаю карго-культ SOLID: інтерфейс з єдиною реалізацією, абстракція над Playwright «на майбутнє», ієрархія заради LSP замість композиції.
- Можу пояснити, чому інтерфейс з єдиною реалізацією не додає гнучкості, а лише файл для стрибка на
Cmd+click.
Квіз
Перед стартом
- Питань: 14
- Поріг «зараховано»: ≥70% правильних відповідей.
- Результат впливає на прогрес; завалені питання підуть у чергу повторення.
- Квіз впливає на компліт теми: тема стає «пройдено», лише коли прочитано теорію І квіз складено на ≥70%.
Питання
SOLID — що це і хто автори?