Принципи дизайну тестового коду: DRY, KISS, YAGNI, композиція
Зміст
Тести — це теж код, який хтось читатиме, правитиме й ламатиме. Різниця в тому, що продуктовий код читають, щоб зрозуміти, що робить система, а тестовий — щоб зрозуміти, що ми про неї стверджуємо. Через цю різницю звичні інженерні принципи в тестах працюють з поправками: те, що для продуктового коду безумовне благо, у тестах іноді шкодить. Найболючіший приклад — DRY, який у наївному застосуванні перетворює прозорий тест на ребус.
Ця глава — про чотири орієнтири (DRY, KISS, YAGNI і композицію), які тримають тестову кодову базу живою на дистанції в роки, а не тижні. Її можна пропустити при першому проході й повернутися, коли у вас уже є свій фреймворк і перші болі з його підтримки — саме тоді ці принципи з абстрактних гасел стають конкретними рішеннями на код-рев'ю. Механізми ООП (успадкування, поліморфізм) розібрані в главі ООП на тестовому коді; тут — про те, коли ними не варто зловживати.
DRY: не повторюйся — і що це насправді означає
DRY (Don't Repeat Yourself) — принцип із книги «The Pragmatic Programmer»: «кожен фрагмент знання повинен мати єдине, однозначне, авторитетне представлення в системі». Ключове слово тут — знання, а не текст. DRY бореться не з візуально схожими рядками, а з дублюванням рішення: якщо бізнес-правило чи спосіб виконати дію живуть у двох місцях, то одного дня їх виправлять в одному й забудуть у другому — і система стане суперечливою.
У тестах справжнє дублювання знання виглядає так: логіка логіну розписана кроками у двадцяти тестах. Змінився флоу авторизації — і ви правите двадцять місць, гарантовано щось пропустивши. Ось це — правильна ціль для DRY: механіку дії виносять у хелпер, фікстуру або метод page object, де вона живе в одному екземплярі.
// Дублювання ЗНАННЯ: як залогінитися — розмазано по тестах
await page.goto('/login');
await page.getByLabel('Email').fill('user@test.io');
await page.getByLabel('Password').fill('Secret123');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL('/dashboard');
Якщо ці п'ять рядків повторюються в кожному тесті, зміна форми логіну — це масове редагування. Виносимо в одне джерело правди:
// login.fixture.ts — механіка входу живе в одному місці
export async function loginAs(page: Page, user: User) {
await page.goto('/login');
await page.getByLabel('Email').fill(user.email);
await page.getByLabel('Password').fill(user.password);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL('/dashboard');
}
Тепер флоу логіну має один авторитетний вигляд. Це DRY у здоровому сенсі.
Межа DRY: чому тести бувають DAMP
А тепер пастка. Спокуса «не повторюватись» тягне винести в спільні хелпери все схоже, зокрема й саму суть перевірки. І тут тест ламається як документація. Порівняйте два варіанти одного тесту.
// Над-DRY: щоб зрозуміти, що перевіряється, треба відкрити три хелпери
test('checkout', async ({ page }) => {
await setupStandardScenario(page, CONFIG.premium);
await runPurchaseFlow(page, DATA.cartA);
await assertHappyPath(page);
});
Що саме тут стверджується? Незрозуміло без стрибків по файлах. Тест перетворився на виклик чорних скриньок, і при падінні assertHappyPath ви не знаєте, що впало, не розгорнувши весь клубок.
// DAMP: намір видно прямо в тесті
test('преміум-користувач бачить безкоштовну доставку в кошику', async ({ page }) => {
await loginAs(page, users.premium); // механіка — з хелпера
await page.goto('/cart');
await cart.addItem('Мишка Logitech');
await expect(cart.shippingLabel).toHaveText('Безкоштовна доставка');
await expect(cart.total).toHaveText('₴1200');
});
Тут працює принцип DAMP (Descriptive And Meaningful Phrases) — «описові й змістовні фрази». Він не заперечує DRY, а розставляє акценти: у тестовому коді читабельність важливіша за мінімізацію рядків. Тест мають розуміти локально, не гортаючи ієрархію абстракцій, бо його головна цінність — бути живою, виконуваною специфікацією поведінки.
Практичне правило межі просте:
- DRY на механіку — те як ми виконуємо дію (логін, підготовка даних, навігація) виносимо в одне джерело: хелпери, фікстури, методи page object.
- DAMP на намір — те що ми перевіряємо (arrange-дані сценарію і всі
expect) лишаємо в тілі тесту, навіть ціною повторів між тестами.
Трохи повторюваних expect у сусідніх тестах — це не борг, це читабельність. А от захований у спільному хелпері assert — майже завжди помилка: він змушує читача довіряти назві функції замість того, щоб бачити перевірку. Детальніше про структуру самого тесту — у главі Анатомія автотесту: AAA.
KISS: найпростіше рішення, що працює
KISS (Keep It Simple, Stupid) — вимога робити найпростіше рішення, яке розв'язує задачу, і не додавати складності без причини. У тестовому коді це особливо гостро, бо тест, який сам складний, не викликає довіри: коли він падає, ви спершу підозрюєте не продукт, а власну хитромудру логіку.
Симптоми порушення KISS у тестах впізнавані. Цикл, що генерує тестові дані «на льоту» з умовами й розгалуженнями замість трьох явних рядків. Власна обгортка над expect, яка «покращує» повідомлення про помилку, а насправді ховає стек. Регулярка з десяти груп там, де вистачило б toContainText. Розумний retry-механізм, накручений навколо дії, яку Playwright і так очікує сам.
// Не-KISS: ручний polling там, де фреймворк чекає сам
let visible = false;
for (let i = 0; i < 10; i++) {
if (await page.locator('.toast').isVisible()) { visible = true; break; }
await page.waitForTimeout(500);
}
expect(visible).toBe(true);
// KISS: web-first assertion з вбудованим авточеканням
await expect(page.getByRole('alert')).toBeVisible();
Другий варіант коротший, надійніший (не залежить від магічних 500 мс) і зрозуміліший. KISS у тестах часто означає «довіряй інструменту»: якщо фреймворк уже вміє чекати, ретраїти асерцію чи ізолювати контекст — не переписуй це руками.
YAGNI і передчасні абстракції
YAGNI (You Aren't Gonna Need It) — принцип із екстремального програмування: не будуй функціональність, поки вона реально не знадобилась. У тестовій автоматизації YAGNI найчастіше порушують на старті проєкту, коли є десять тестів, а вже пишеться «універсальний фреймворк на виріст»: базовий клас із параметром на будь-який браузер, фабрика драйверів для СУБД, яких у проєкті немає, шар абстракції над Playwright «щоб легко переїхати на інший інструмент».
Ціна передчасної абстракції в тестах висока з двох причин. По-перше, ви платите за неї підтримкою вже сьогодні, а окупність — гіпотетична й у майбутньому, яке може не настати. По-друге — і це специфіка тестів — абстракція, витягнута з одного-двох випадків, майже завжди вгадана неправильно. Коли з'явиться третій реальний випадок, він не влізе в передчасну форму, і ви або ламатимете абстракцію, або натягуватимете кейс на неї прапорцями-boolean.
Здорове емпіричне правило — «правило трьох»: перший раз просто пишеш, другий — терпиш дублювання й помічаєш його, і лише на третьому подібному випадку виносиш спільне. Три реальні приклади показують справжню вісь варіативності; два — брешуть.
// Передчасно: узагальнення на основі ОДНОГО екрана
abstract class BasePage<TConfig> {
constructor(protected cfg: TConfig) {}
abstract get url(): string;
async openWithRetries(times = 3): Promise<void> { /* ... */ }
async waitForAnalytics(): Promise<void> { /* ще нікому не треба */ }
}
Дженерик-параметр, ретраї відкриття й хук аналітики тут — рішення проблем, яких ще немає. YAGNI каже: видали їх, додаси, коли третій екран справді цього попросить.
Композиція проти успадкування
Це канонічна тема глави: коли поведінку об'єкта збирають із частин (композиція, зв'язок «має» — has-a), а коли успадковують від базового класу (зв'язок «є» — is-a). Класичне формулювання з книги «банди чотирьох» (Gang of Four, «Design Patterns») — надавайте перевагу композиції об'єктів над успадкуванням класів. У тестовому коді це правило працює ще жорсткіше, ніж у продуктовому.
Спокуса успадкування в тестах виглядає невинно: винесемо спільне в BaseTest, від нього — BaseUiTest, від нього — BaseAuthenticatedTest, і так далі. Кожен крок логічний, а через півроку виходить ланцюг із чотирьох рівнів, у якому неможливо відповісти на просте питання: «звідки в цьому тесті взявся залогінений юзер?».
Глибокі ієрархії болять із конкретних причин:
- Крихкий базовий клас (fragile base class). Зміна в
BaseTestрозходиться по всіх нащадках непередбачувано. Ви поправилиbeforeEachзаради одного тесту — впало п'ятдесят інших, бо вони мовчки залежали від старої поведінки. - Вертикальне читання. Щоб зрозуміти один тест, доводиться тримати в голові весь ланцюг предків: що ініціалізує кожен рівень, який
setupу якому порядку відпрацював. Логіка розмазана по вертикалі, іCmd+clickведе на порожній абстрактний метод. - Жорсткість. Успадкування фіксується під час написання класу — обрати іншу комбінацію поведінок для конкретного тесту вже не можна. Потрібен тест із браузером, але без автологіну — і він не вписується в ієрархію, де логін «зашитий» посередині.
- Успадкування заради перевикористання, а не заради «є».
CheckoutTestне є різновидомBaseAuthTest— він просто хоче користуватися логіном. Це відношення «має», яке натягнули на «є» лише щоб не писати код двічі.
Композиція знімає всі чотири болі. Замість того щоб бути нащадком, тест має потрібні здатності — отримує їх через фікстури або поля. У Playwright це рідна модель: фікстури (test.extend) віддають тесту рівно ті об'єкти, які він попросив у параметрах, у явному, читабельному вигляді.
// Композиція через фікстури: тест ЯВНО оголошує, що йому треба
export const test = base.extend<{ authedPage: Page; cart: CartPage }>({
authedPage: async ({ page }, use) => {
await loginAs(page, users.premium);
await use(page);
},
cart: async ({ authedPage }, use) => {
await use(new CartPage(authedPage));
},
});
test('кошик рахує суму', async ({ cart }) => {
// видно з сигнатури: тесту потрібен кошик поверх залогіненої сторінки
await cart.open();
await expect(cart.total).toHaveText('₴1200');
});
Той самий принцип — усередині page object. Замість того щоб CheckoutPage успадковувала HeaderPage, вона містить компоненти: header, dataTable, paymentForm. Кожен компонент — окремий, повторно вживаний клас зі своїми локаторами й діями. Це і є перехід від глибоких ієрархій до плоскої композиції компонентів, детально розібраний у главі Page Object: від класики до компонентів.
// has-a: сторінка СКЛАДАЄТЬСЯ з компонентів, а не успадковує їх
class CheckoutPage {
readonly header = new HeaderComponent(this.page);
readonly cart = new CartComponent(this.page);
constructor(private page: Page) {}
}
Це не означає «успадкування заборонене». Тонкий BaseComponent з полем page і спільним конструктором — законний один рівень. Правило радше про глибину й мотив: успадкування виправдане, коли між класами справді є відношення «є» і ланцюг неглибокий; для перевикористання поведінки майже завжди краща композиція. Систематичніший погляд на це дає принцип підстановки Лісков і решта SOLID у тест-фреймворку.
Код-рев'ю тестового коду
Принципи вище стають реальністю тільки на рев'ю — інакше вони лишаються гаслами у вікі. Але тестовий код рев'ю за іншим списком, ніж продуктовий: тут головне питання не «чи ефективно це написано», а «чи зрозуміло, що і чому перевіряється, і чи не розвалиться це на першому редизайні».
На що дивитись у пул-реквесті з тестами:
- Читабельність намі́ру. Чи видно з тіла тесту, що́ він стверджує, без стрибків у хелпери? Якщо перевірку заховано в спільну функцію — це привід повернути DAMP у тест.
- Локатори. Прив'язка до
data-testid, ролей і текстів, а не до крихких CSS-шляхів і згенерованих класів. Це найчастіша причина майбутнього флаку (див. Локатори: стратегія стабільних селекторів). - Синхронізація. Жодного
waitForTimeoutіз магічним числом — тільки web-first очікування. Хардкоджені паузи — це відкладений флак. - Незалежність та ізоляція. Тест не спирається на дані, створені сусіднім тестом, і сам прибирає за собою. Спільний мутабельний стан між тестами — червоний прапорець.
- Передчасні абстракції. Новий «універсальний» базовий клас чи шар обгорток під один кейс — привід спитати «а це вже треба, чи YAGNI?».
- Свіжість даних. Хардкоджені id, email, дати, які зламаються завтра або на іншому середовищі (стратегія — у главі Тест-дані: фабрики, фікстури, ізоляція).
Корисна рев'ю-евристика: якщо, щоб зрозуміти один тест, доводиться відкрити більше двох-трьох інших файлів — абстракцій забагато. Тест має читатися майже як речення природною мовою.
Типові помилки
- Виглядає як DRY, а насправді втрата читабельності. Винесли
assertу спільний хелпер «щоб не повторюватись» — і тепер при падінні незрозуміло, що саме зламалось. DRY стосується механіки дії, а не суті перевірки. - Виглядає як єдине джерело правди, а насправді хибне узагальнення. Два схожі шматки коду об'єднали в одну функцію, бо вони зараз однакові. Це збіг, а не спільне знання: щойно один зміниться, функція обросте прапорцями
if (isPremium). Однаковий вигляд — не привід для DRY; привід — однакове рішення. - Виглядає як передбачливість, а насправді YAGNI-порушення. «Фреймворк на виріст» із абстракціями під майбутні інструменти, яких у проєкті нема. Ви платите підтримкою вже, окупність — гіпотетична.
- Виглядає як гарна архітектура, а насправді ієрархія болю. Ланцюг
BaseTest → ... → MyTestна чотири рівні виглядає структуровано, але щоб зрозуміти один тест, треба прочитати всіх предків. Композиція через фікстури й компоненти читається локально. - Виглядає як is-a, а насправді has-a.
CheckoutTest extends LoginTest— але checkout не є різновидом логіну, він ним користується. Успадкування заради перевикористання — класична пастка.
Підсумок
- DRY стосується дублювання знання (одного рішення в кількох місцях), а не візуально схожих рядків; у тестах його ціль — механіка дій, а не суть перевірок.
- Тести свідомо бувають DAMP: намір і асерції лишаються в тілі тесту заради читабельності, навіть ціною повторів між тестами.
- KISS і YAGNI у тестах — це довіра до інструмента (не переписуй авточекання руками) і відмова від абстракцій, поки їх не попросили три реальні випадки.
- Композиція перемагає успадкування: глибокі ієрархії дають крихкий базовий клас, вертикальне читання й жорсткість; збирайте поведінку з фікстур і компонентів (has-a), а не з ланцюгів предків (is-a).
- Ці принципи живуть на код-рев'ю: тест має читатися локально, без стрибків по абстракціях, зі стабільними локаторами й без хардкоджених пауз.
Що питають на співбесіді
- «DRY чи не DRY у тестах? Де межа?» Інтерв'юер перевіряє, чи розумієте ви, що тести — теж документація. Сильна відповідь: DRY — на механіку (логін, підготовка даних) через хелпери й фікстури; намір і асерції лишаємо в тесті (DAMP), бо його треба читати локально.
- «Композиція чи успадкування — що обрати для page object / базового тесту і чому?» Тут чекають на «надаю перевагу композиції» з причинами: крихкий базовий клас, глибина ланцюга, читабельність. Плюс приклад: компоненти всередині сторінки або фікстури Playwright замість
extends. - «Що не так із глибокою ієрархією BaseTest?» Очікують перелік болів: розповсюдження змін по нащадках, неможливість зрозуміти тест без предків, жорсткість комбінацій setup.
- «Наведіть приклад передчасної абстракції в автоматизації.» Перевіряють YAGNI на практиці: «фреймворк на виріст», обгортка над інструментом «щоб легко переїхати», узагальнення з одного кейса. Згадайте правило трьох.
- «На що дивитесь на рев'ю тестового коду?» Читабельність наміру, стабільні локатори, відсутність
waitForTimeout, незалежність тестів, свіжість даних. Це питання на зрілість, а не на знання синтаксису.
Джерела
- Andrew Hunt, David Thomas. The Pragmatic Programmer — першоджерело принципу DRY і формулювання «єдине авторитетне представлення знання».
- Gerard Meszaros. xUnit Test Patterns: Refactoring Test Code — канон про запахи тестового коду, читабельність тестів і межу DRY у тестах.
- Erich Gamma et al. Design Patterns (Gang of Four) — джерело принципу «favor object composition over class inheritance».
- Martin Fowler. Yagni — розбір принципу YAGNI і ціни передчасних узагальнень.
- Спеціального розділу в силабусі ISTQB CTFL 4.0 ці принципи не мають — це загальноінженерні практики; у контексті автоматизації їх варто читати разом із главами про SOLID і Page Object.
Що таке принцип DRY і чому «повторення» в ньому стосується не схожих рядків?
DRY (Don't Repeat Yourself) — вимога, щоб кожен елемент знання в системі мав одне авторитетне джерело. Ключове слово тут — знання, а не текст: принцип цілиться в дублювання рішення (бізнес-правила чи способу виконати дію), а не візуально однакових рядків. Небезпека справжнього дубля в тому, що одного дня його поправлять в одному місці й забудуть в іншому — і система стане суперечливою. У тестах хрестоматійний приклад — покрокова логіка логіну, розписана в десятках тестів: варто змінитися формі входу, і треба редагувати кожну копію. А от два фрагменти, які просто зараз схожі, але концептуально незалежні, дублем знання не є — об'єднувати їх не обов'язково.
Чому тестовий код інколи навмисно роблять DAMP, а не максимально DRY?
Бо тест — це ще й документація: його читають, щоб зрозуміти, що ми стверджуємо про систему. Принцип DAMP (Descriptive And Meaningful Phrases) ставить читабельність вище за мінімізацію рядків. Якщо гнатися за DRY і ховати суть перевірки під шар абстракцій, тест вироджується в набір викликів чорних скриньок: коли він падає, незрозуміло, що саме зламалось, без розкручування всього клубка. DAMP не заперечує DRY, а розставляє акценти — намір і асерції видно локально, прямо в тілі тесту. Невеликий дубль expect між сусідніми тестами тут прийнятна ціна за те, що тест читається як специфікація поведінки.
Де проходить межа: що виносити у спільне, а що лишати в тілі тесту?
Робоча евристика формулюється парою: DRY на механіку, DAMP на намір. Механіка — це як ми виконуємо дію: логін, підготовка даних, навігація; її виносять в одне джерело — хелпер, фікстуру або метод page object. Намір — це що ми перевіряємо: arrange-дані конкретного сценарію і всі expect; вони лишаються в тілі тесту, навіть ціною повторів між тестами. Тобто спільною стає рутина, а унікальним — твердження. Простий критерій, що межу проведено правильно: читаючи тест, ви розумієте, що він перевіряє, не відкриваючи інші файли.
Чому винесення expect у спільний хелпер — майже завжди помилка?
Тому що перевірка і є суттю тесту, а сховати її означає змусити читача довіряти назві функції замість того, щоб бачити саме твердження. Коли умовний assertHappyPath(page) падає, ви не знаєте, який конкретно expect усередині червоний, поки не відкриєте хелпер, — а це прямо суперечить ролі тесту як документації. Механіку дії ховати можна й треба, а суть перевірки — ні: вона має лишатися перед очима. Єдиний розумний виняток — тонкі доменні матчери, які роблять асерцію виразнішою, а не ховають її.
Що таке KISS і як він проявляється в автотестах?
KISS (Keep It Simple, Stupid) — вимога брати найпростіше рішення, яке розв'язує задачу, і не додавати складності без причини. У тестах це особливо гостро: складний тест не викликає довіри, бо коли він червоніє, першою підозрюють не продукт, а власну хитромудру логіку. Часто KISS зводиться до «довіряй інструменту» — якщо фреймворк уже вміє чекати, ретраїти асерцію чи ізолювати контекст, не переписуй це руками. Простіший тест водночас і надійніший, і зрозуміліший, і дешевший у підтримці.
Наведіть приклад порушення KISS у Playwright-тесті.
Найтиповіший — ручний polling там, де є web-first assertion. Замість циклу з waitForTimeout(500) і власним прапорцем, який чекає появи елемента, достатньо await expect(locator).toBeVisible(): воно саме перечікує й ретраїть до таймауту. Інші впізнавані симптоми: обгортка над expect, яка «покращує» повідомлення, а насправді ховає стек; регулярка на десять груп там, де вистачає toContainText; генерація даних циклом з розгалуженнями замість трьох явних рядків. Спільне в усіх випадках — рукотворна складність поверх того, що інструмент уже вміє сам.
Що таке YAGNI і чому передчасні абстракції в тестах дорожчі, ніж у продукті?
YAGNI (You Aren't Gonna Need It) — принцип з екстремального програмування: не будуй функціональність, поки вона реально не знадобилась. У тестовій автоматизації його порушують на старті, коли є десять тестів, а вже пишеться «універсальний фреймворк на виріст» — базові класи й фабрики драйверів під те, чого в проєкті немає. Ціна висока з двох причин. По-перше, за абстракцію ви платите підтримкою вже сьогодні, а окупність гіпотетична й у майбутньому, яке може не настати. По-друге — і це специфіка тестів — форму абстракції, витягнутої з одного-двох випадків, майже завжди вгадують неправильно: третій реальний кейс у неї не влізе, і доведеться або ламати її, або натягувати кейс на неї прапорцями.
Що таке «правило трьох» і навіщо воно?
Це емпіричне правило, яке підказує момент для узагальнення: перший раз просто пиши, другий — стерпи дубль і поміть його, і лише на третьому подібному випадку виноси спільне. Сенс у тому, що два приклади брешуть про вісь варіативності, а три вже показують її по-справжньому — видно, що насправді спільне, а що відрізняється. Це протиотрута і від передчасних абстракцій (винесли зарано — вгадали форму неправильно), і від сліпого DRY. Правило не догма, а нагадування зачекати з узагальненням, поки не набереться достатньо доказів.
У чому різниця між композицією (has-a) і успадкуванням (is-a)?
Успадкування — це відношення «є» (is-a): клас-нащадок є різновидом базового й дістає його поведінку через ланцюг предків. Композиція — відношення «має» (has-a): об'єкт збирає потрібну поведінку з окремих частин, які містить у собі. Канонічна порада «банди чотирьох» (Gang of Four) — надавати перевагу композиції об'єктів над успадкуванням класів. Практичний тест на вибір: якщо між класами справді відношення «є» і ланцюг неглибокий — успадкування доречне; якщо ви успадковуєте лише щоб перевикористати код, це насправді «має», натягнуте на «є».
Чому в тестовому коді композицію віддають перевагу особливо жорстко?
Тому що тести читають поодинці, а глибокі ієрархії роблять це неможливим. Спокуса виглядає невинно: спільне виносимо в BaseTest, від нього — BaseUiTest, далі — BaseAuthTest; кожен крок логічний, а через півроку маєш ланцюг на чотири рівні, у якому не відповіси на просте «звідки в цьому тесті взявся залогінений юзер». Композиція через фікстури й компоненти лишає це видимим: тест явно оголошує, що йому треба, і поведінка збирається локально. До того ж успадкування фіксує комбінацію під час написання класу, а фікстури дають зібрати рівно те, що потрібно конкретному тесту.
Що таке «крихкий базовий клас» (fragile base class)?
Це ситуація, коли зміна в базовому класі непередбачувано розходиться по всіх нащадках. Ви поправили beforeEach заради одного тесту — і впало п'ятдесят інших, бо вони мовчки залежали від старої поведінки. Проблема тим гостріша, чим глибший ланцюг і чим більше нащадків: базовий клас стає таким, що його страшно чіпати, а будь-яке редагування перетворюється на лотерею. Це один з головних аргументів проти глибоких ієрархій у тестах, де setup часто зашитий саме в базу.
Як фікстури Playwright реалізують композицію?
Фікстура (test.extend) віддає тесту рівно ті об'єкти, які він попросив у своїх параметрах, — у явному, читабельному вигляді. Замість того щоб бути нащадком якогось BaseAuthTest, тест має потрібні здатності: оголошуєш у сигнатурі cart — і фреймворк збирає для тебе кошик поверх залогіненої сторінки. Фікстури можуть залежати одна від одної (cart будується на authedPage, той — на page), тож поведінка компонується ланцюжком, але видимим і плоским, а не захованим у ієрархії предків. Це рідна для Playwright модель, і саме тому в ньому рідко потрібне успадкування тестових класів.
Успадкування в тестах не заборонене повністю — коли воно виправдане?
Коли між класами справді є відношення «є» і ланцюг неглибокий. Тонкий BaseComponent з полем page і спільним конструктором — законний один рівень: усі компоненти справді є компонентами. Правило не про повну заборону, а про глибину й мотив: погано, коли успадкування тягнуть заради перевикористання поведінки (це мала б бути композиція) або коли будують ланцюг на кілька поверхів. Один тонкий базовий рівень з чіткою семантикою «є» — нормально; ієрархія setup на чотири рівні — вже борг.
Як принцип композиції проявляється всередині page object?
Замість того щоб CheckoutPage успадковувала HeaderPage, вона містить компоненти: header, cart, paymentForm. Кожен компонент — окремий повторно вживаний клас зі своїми локаторами й діями, а сторінка складається з них. Це і є перехід від глибоких ієрархій сторінок до плоскої композиції компонентів: додати новий блок — означає додати поле, а не наростити ще один рівень extends. Вигода та сама, що й у тестах: локальна читабельність і відсутність крихкого базового класу.
На що дивитись на код-рев'ю тестового коду — і чим воно відрізняється від рев'ю продуктового?
Головне питання тут не «чи ефективно написано», а «чи зрозуміло, що і чому перевіряється, і чи не розвалиться це на першому редизайні». Практичний чек-лист: чи видно намір тесту без стрибків у хелпери (інакше треба повернути асерції в тіло); чи локатори прив'язані до data-testid, ролей і текстів, а не до крихких CSS-шляхів; чи немає waitForTimeout з магічним числом; чи тест незалежний, ізольований і прибирає за собою; чи не з'явився передчасний «універсальний» базовий клас; чи дані свіжі, а не хардкоджені id та дати. Корисна евристика: якщо для розуміння одного тесту доводиться відкрити більше двох-трьох файлів — абстракцій забагато.
«Виглядає як DRY, а насправді втрата читабельності» — поясніть цю пастку.
Це коли під гаслом «не повторюватись» у спільний хелпер виносять не механіку, а саму суть перевірки. Формально дубль прибрано, а фактично тест осліп: при падінні незрозуміло, який конкретно інваріант зламався, поки не розгорнеш хелпер. Причина плутанини в тому, що DRY стосується дублювання рішення (механіки дії), а не тверджень, які й мають бути перед очима. Лікування просте — повернути асерції в тіло тесту (DAMP), лишивши спільним тільки те, як виконується дія.
Два схожі шматки коду об'єднали в одну функцію — коли це помилка?
Коли вони схожі випадково, а не тому, що виражають одне рішення. Однаковий вигляд — не привід для DRY; привід — однакове знання. Якщо два фрагменти зараз збігаються, але концептуально незалежні, то щойно один зміниться, спільна функція обросте прапорцями на кшталт if (isPremium) й розгалуженнями — тобто перетвориться на два кейси, зшиті в один, який читати важче, ніж два явні шматки. Це хибне узагальнення. Тест на справжній дубль простий: спитати, чи змінюватимуться ці місця разом і з однієї причини.
CheckoutTest extends LoginTest — що тут не так?
Порушено семантику успадкування: checkout не є різновидом логіну, він ним користується. Це відношення «має», яке натягнули на «є» лише щоб не писати код входу двічі — класична пастка «успадкування заради перевикористання». Наслідки типові: логін зашитий посередині ієрархії, тож не можна зробити тест checkout без автологіну; будь-яка зміна в LoginTest б'є по всіх нащадках. Правильно винести логін у фікстуру чи хелпер — і тест checkout просто отримає залогінену сесію як здатність, а не успадкує її разом з усім рештою.
Три ситуації з рев'ю, де ці принципи перестають бути гаслами й стають конкретним рішенням на код: над-DRY тест, який осліп, і повернення його в DAMP; передчасна абстракція, розпізнана за «правилом трьох»; та розплутування ланцюга успадкування BaseTest у плоскі фікстури Playwright. Скрізь — що змінюємо і чому.
Кейс 1. Над-DRY тест: чому «нуль повторень» коштує читабельності
На рев'ю прилетів тест, який автор гордо стиснув до трьох рядків. Дублювання нема — і саме в цьому проблема.
// БУЛО: намір заховано в трьох хелперах
test('checkout premium', async ({ page }) => {
await setupScenario(page, CONFIG.premium);
await runPurchase(page, DATA.cartA);
await assertHappyPath(page); // ← що саме тут перевіряється?
});
Питання рев'ю: коли assertHappyPath почервоніє в CI, що ми дізнаємось? Нічого — доведеться відкрити хелпер, потім CONFIG.premium, потім DATA.cartA, щоб зібрати в голові, який інваріант зламався. Тест перестав бути документацією й став викликом трьох чорних скриньок. Повертаємо намір у тіло, лишаючи спільною тільки механіку входу:
// СТАЛО: механіка спільна, намір і асерції — локальні
test('преміум-користувач має безкоштовну доставку в кошику', async ({ page }) => {
await loginAs(page, users.premium); // ← механіка з хелпера, це ОК
await page.goto('/cart');
await cart.addItem('Мишка Logitech');
await expect(cart.shippingLabel).toHaveText('Безкоштовна доставка');
await expect(cart.total).toHaveText('₴1200');
});
Щоб рішення «виносити чи лишати» не було на око, корисно тримати в голові таку таблицю межі:
| Що це в тесті | DRY чи DAMP | Куди воно йде |
|---|---|---|
| Логін, реєстрація, підготовка даних | DRY | хелпер / фікстура / page object |
| Навігація й службові кроки | DRY | метод page object |
| Arrange-дані конкретного сценарію | DAMP | тіло тесту |
Усі expect (суть перевірки) | DAMP | тіло тесту |
Що дивитися і чому:
- Спільний
assert— майже завжди помилка. Механіку дії ховати можна, суть перевірки — ні: вона має бути перед очима, інакше падіння не діагностувати без розкручування клубка. - Дубль двох
expectміж сусідніми тестами — не борг. Це читабельність. Стискати його вassertHappyPath— міняти діагностованість на ілюзію чистоти. - Назва тесту теж документація.
checkout premiumне каже нічого;преміум-користувач має безкоштовну доставку— це вже інваріант, який видно ще до коду.
Кейс 2. Правило трьох: як відрізнити абстракцію від передчасного вгадування
Приходить PR: другий за тиждень тест на «список» (спершу список замовлень, тепер список рахунків), і автор одразу виносить BaseListPage з дженериком і хуками «на майбутнє».
// Узагальнення на основі ДВОХ екранів
abstract class BaseListPage<TRow> {
abstract get rowSelector(): string;
async openWithRetries(times = 3): Promise<void> { /* ще нікому не треба */ }
async waitForAnalytics(): Promise<void> { /* ще нікому не треба */ }
async sortBy(col: string, dir: 'asc' | 'desc'): Promise<TRow[]> { /* ... */ }
}
Реакція на рев'ю — не «гарна архітектура», а питання: у нас два списки чи три? Бо два приклади показують вісь варіативності неправдиво. Проженемо по «правилу трьох»:
| Крок | Що робити | Стан абстракції |
|---|---|---|
| 1-й список | просто написати | ще немає осі варіативності |
| 2-й список | стерпіти дубль, помітити його | форма ще вгадується |
| 3-й список | винести спільне за трьома фактами | вісь видно по-справжньому |
openWithRetries і waitForAnalytics тут узагалі рішення проблем, яких немає, — чистий YAGNI. А сам BaseListPage, вгаданий з двох екранів, майже напевно не влізе на третій: у рахунків з'явиться пагінація, якої не було в замовлень, і базовий клас обросте прапорцями. Порада в PR: залишити два списки як є, дубль стерпіти, а до узагальнення повернутися, коли прийде третій реальний випадок.
Що дивитися і чому:
- Новий «універсальний» базовий клас під один-два кейси — червоний прапор рев'ю. Питання «це вже треба, чи YAGNI?» доречніше за схвалення передбачливості.
- Хуки «на виріст» (
waitForAnalytics) видаляємо одразу. За них платять підтримкою вже сьогодні, а окупність гіпотетична. - Два приклади брешуть, три показують вісь. Це не бюрократія, а спосіб не вгадати форму абстракції неправильно й не переробляти її потім через прапорці.
Кейс 3. Від ланцюга успадкування до фікстур: розплутуємо BaseTest
Класична спадщина зрілого фреймворку — ланцюг, який виріс «логічними кроками»:
// БУЛО: is-a на чотири рівні
class BaseTest { /* setup/teardown */ }
class BaseUiTest extends BaseTest { /* + браузер */ }
class BaseAuthTest extends BaseUiTest { /* + автологін, зашитий посередині */ }
class CheckoutTest extends BaseAuthTest { /* власне тест */ }
Болить одразу все: щоб зрозуміти CheckoutTest, треба тримати в голові всіх предків і порядок їхніх setup; зміна в BaseTest розходиться по всіх нащадках непередбачувано (крихкий базовий клас); а зробити тест з браузером, але без автологіну неможливо — логін «зашитий» посередині ланцюга. І головне: CheckoutTest не є різновидом BaseAuthTest, він просто хоче користуватися логіном. Це has-a, натягнуте на is-a. Переписуємо на композицію — тест явно оголошує, що йому треба:
// СТАЛО: has-a через фікстури, поведінка збирається видимо й плоско
export const test = base.extend<{ authedPage: Page; cart: CartPage }>({
authedPage: async ({ page }, use) => {
await loginAs(page, users.premium);
await use(page);
},
cart: async ({ authedPage }, use) => {
await use(new CartPage(authedPage));
},
});
test('кошик рахує суму', async ({ cart }) => {
// з сигнатури видно: тесту треба кошик поверх залогіненої сторінки — і все
await cart.open();
await expect(cart.total).toHaveText('₴1200');
});
Той самий перехід — усередині page object: сторінка не успадковує хедер, а містить компоненти як поля.
// has-a: сторінка складається з компонентів, а не успадковує їх
class CheckoutPage {
readonly header = new HeaderComponent(this.page);
readonly cart = new CartComponent(this.page);
constructor(private page: Page) {}
}
Що дивитися і чому:
- Тест, якому браузер потрібен без автологіну, більше не «не вписується». Він просто не оголошує фікстуру
authedPage— комбінацію збирають під конкретний кейс, а не фіксують у ланцюзі предків. Cmd+clickведе на реальний код, а не на порожній абстрактний метод. Фікстура — це видиме джерело поведінки, а не рівень ієрархії, який треба тримати в голові.- Один тонкий рівень — це нормально.
class HeaderComponent extends BaseComponentз полемpage— законне «є». Правило про глибину й мотив, а не про заборонуextendsяк такого.
DRY і DAMP
- Знаю, що DRY стосується дублювання знання (одного рішення в кількох місцях), а не візуально схожих рядків.
- Розумію правило межі: DRY на механіку (логін, підготовка даних, навігація), DAMP на намір (arrange-дані сценарію й усі
expect). - Можу пояснити, що таке DAMP і чому в тестах читабельність важливіша за мінімізацію рядків.
- Знаю, чому винесення
expectу спільний хелпер ховає суть перевірки й ускладнює діагностику падіння. - Можу відрізнити справжній дубль (однакове рішення) від випадкового збігу двох незалежних фрагментів.
KISS
- Розумію KISS як «найпростіше рішення, що працює» і чому складний тест сам підриває довіру до себе.
- Знаю, що KISS у тестах часто означає «довіряй інструменту»: web-first assertion замість ручного polling.
- Можу назвати симптоми порушення KISS: обгортка над
expect, зайва регулярка, рукотворнийretry, генерація даних циклом із розгалуженнями.
YAGNI і передчасні абстракції
- Розумію YAGNI: не будувати функціональність, поки вона реально не знадобилась.
- Можу пояснити, чому передчасна абстракція в тестах дорожча — платиш підтримкою вже, а форму вгадуєш з одного-двох кейсів неправильно.
- Знаю «правило трьох»: виносити спільне лише на третьому подібному випадку, бо два приклади брешуть про вісь варіативності.
- Можу навести приклад передчасної абстракції: «фреймворк на виріст», шар над Playwright «щоб легко переїхати», базовий клас під один екран.
Композиція проти успадкування
- Знаю різницю has-a (композиція, «має») vs is-a (успадкування, «є») і правило «надавай перевагу композиції над успадкуванням».
- Можу перелічити болі глибокої ієрархії: крихкий базовий клас, вертикальне читання, жорсткість комбінацій, успадкування заради перевикористання.
- Розумію, як фікстури Playwright (
test.extend) реалізують композицію — тест явно оголошує потрібні здатності в сигнатурі. - Знаю, що композиція в page object — це компоненти (
header,cart) як поля, а не ланцюгextends. - Можу сказати, коли успадкування виправдане: справжнє «є» плюс неглибокий ланцюг (тонкий
BaseComponentз полемpage).
Код-рев'ю тестового коду
- Знаю, чим рев'ю тестів відрізняється від продуктового: головне — читабельність наміру й стійкість до редизайну, а не ефективність.
- Можу перелічити, на що дивитись: намір без стрибків у хелпери, стабільні локатори, відсутність
waitForTimeout, незалежність та ізоляція, свіжість даних, відсутність передчасних абстракцій. - Пам'ятаю евристику: якщо для розуміння одного тесту треба відкрити більше двох-трьох файлів — абстракцій забагато.
Чого насправді стосується принцип DRY?
Питання
DRY стосується дублювання чого?