SOLID у тест-фреймворку
Зміст
SOLID випливає на співбесіді AQA з двох боків. Перший — прямий: інтерв'юер (часто з бекенд-фоном) просить «розкажіть про SOLID», і кандидат переказує п'ять означень з блогу, не давши жодного прикладу зі свого фреймворку. Другий — прихований, у щоденній роботі: тестовий проєкт із півсотні тестів починає гнити — god object на тисячу рядків, 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 писали для продуктового коду — довговічних модулів, які використовують багато місць. У тестах він застосовується з поправкою (докладно — в останньому розділі): найбільше він потрібен для інфраструктури тестів (page object, клієнти, фікстури), а не для тіла окремого тесту, яке має лишатися пласким і читатися згори вниз.
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-хелпер, який уміє все на світі.
// Порушення 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. Тест оголошує в сигнатурі, що йому потрібна абстракція, а фікстура вирішує, яку реалізацію підкласти.
// Абстракція, від якої залежить тест (з глави про ООП)
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, і рефакторинг мав би прибирати абстракції, а не додавати.
Типові помилки
- Виглядає як 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.
- «Коли SOLID у тестах — це перебір?» — найсильніше питання, відрізняє мідла від того, хто вивчив акронім. Очікують: тести — це документація, надлишкові абстракції вбивають читабельність; SOLID — для інфраструктури (page object, клієнти, фікстури), тіло тесту лишається пласким; інтерфейс з однією реалізацією — запах.
Джерела
- SOLID — Wikipedia — оглядовий індекс п'яти принципів із першоджерелами кожного.
- Liskov substitution principle — Wikipedia — формулювання підстановки й приклади порушень.
- Playwright — Test fixtures — впровадження залежностей (DI) як механізм DIP у тестах.
- Robert C. Martin. Clean Architecture (2017) — канонічне зведення SOLID як набору з розбором SRP і DIP.
- Спеціального розділу в силабусі ISTQB CTFL 4.0 SOLID не має — це загальноінженерна, а не тест-аналітична тема; у контексті автоматизації її читають разом із главами про ООП і принципи дизайну тестового коду.
Що таке SOLID і чому цю тему питають саме в AQA?
SOLID — набір із п'яти принципів об'єктно-орієнтованого дизайну, які сформулював Роберт Мартін, а в акронім склав Майкл Фізерс. Спільна мета всіх п'яти — послабити зчеплення (coupling) між частинами коду, щоб правка в одному місці не розповзалася на весь проєкт. В автоматизації тема виринає двома шляхами: прямо (інтерв'юер із бекенд-фоном просить «розкажіть про SOLID») і приховано — коли тестовий проєкт розростається, з'являється god object на тисячу рядків, а один хелпер чіпаєш заради дрібниці й ламаєш десяток тестів. Оскільки тестовий код правлять не рідше за продукт, який він перевіряє, локалізація змін тут особливо цінна. Слабка відповідь — переказати п'ять означень з блогу; сильна — до кожної літери дати приклад із власного фреймворку.
Що таке принцип єдиної відповідальності (SRP) і як він порушується в page object?
SRP каже, що в модуля має бути лише одна причина змінюватися — він служить одному «акторові». У тестах його найчастіше вбиває god page object, який одночасно клацає по формі, стверджує результат, створює тест-дані через 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.
Що таке принцип інверсії залежностей (DIP)?
DIP каже: високорівневий модуль не має залежати від низькорівневого — обидва залежать від абстракції, і деталі залежать від абстракції, а не навпаки. У тестах високий рівень — це сам тест, що вирішує що перевірити; низький — конкретика як саме: яка HTTP-бібліотека, реальний сервіс чи мок, логін через форму чи через API. DIP вимагає, щоб тест цієї конкретики не знав: він оголошує потребу в абстракції (наприклад, інтерфейсі LoginFlow), а хтось інший вирішує, яку реалізацію підкласти. Практичний виграш — ту саму перевірку можна прогнати проти реального й проти мок-сервісу, не змінивши жодного рядка тесту. Стрілка залежності перевернута: деталі вказують на абстракцію.
У чому різниця між DIP і DI?
Це найчастіша плутанина на співбесіді. DIP (dependency inversion principle) — це принцип: залежати від абстракції, а не від конкретної реалізації. DI (dependency injection) — це механізм, яким принцип досягають: залежність не створюють усередині об'єкта через new, а передають ззовні. Одне — мета, друге — спосіб її досягти. В автоматизації найважливіший приклад: фікстури Playwright — це і є DI, тож коли тест оголошує залежність у сигнатурі, а фікстура вирішує, яку реалізацію дати, ви застосовуєте DIP через механізм DI. Можна досягти DIP і без контейнера DI (передати залежність аргументом конструктора вручну), тому змішувати ці поняття — ознака поверхового розуміння.
Чому кажуть, що фікстури Playwright дають DIP «майже даром»?
Бо механіка інверсії вже вбудована в ранер: тест оголошує в сигнатурі, що йому потрібен, скажімо, loginFlow, а фікстура сама вирішує, підкласти UiLogin чи ApiLogin залежно від оточення. Тестові тіла не створюють залежності через new ApiClient(...), а отримують готові — це і є впровадження залежностей, тобто DIP у дії. Тому якщо команда користується фікстурами замість ручного інстанціювання клієнтів у кожному тесті, вона вже застосовує 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 page object по 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. На співбесіді ці два поняття треба розводити явно. - Абстракція виправдана, бо реалізацій справді дві. Тут інтерфейс не «про всяк випадок»: є 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 рушія по власному діалекту й ускладнює дебаг.
- Розведення god object приймаємо без вагань. Це той випадок, коли SOLID б'є в ціль: page object отримав одну причину змінюватися, тест став читабельнішим, сідінг перевикористовується. Межа проста — принцип застосовуємо до інфраструктури з багатьма викликачами, а не заводимо абстракцію заради самої абстракції.
SOLID як рамка
- Можу назвати п'ять принципів (SRP, OCP, LSP, ISP, DIP) і одним реченням сказати, за що відповідає кожен.
- Розумію спільну мету всіх п'яти: послабити зчеплення, щоб зміна лишалася локальною.
- Знаю, що SOLID писали для продуктового коду, і вмію пояснити, з якою поправкою він переноситься на тести.
- Можу дати до кожної літери приклад із власного тестового фреймворку, а не переказати означення з блогу.
SRP і OCP
- Можу пояснити SRP через «одну причину змінюватися», а не через «один метод у класі».
- Розпізнаю god page object і знаю, як розвести локатори, дії, асерції та сідінг за відповідальностями.
- Розумію, чому асерції живуть у тілі тесту, а не всередині 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.
Яка спільна мета всіх п'яти принципів SOLID?
Питання
SOLID — що це і хто автори?