Принципи дизайну тестового коду: DRY, KISS, YAGNI, композиція
Зміст
Тести — це теж код, який хтось читатиме, правитиме й ламатиме. Різниця в тому, що продуктовий код читають, щоб зрозуміти, що робить система, а тестовий — щоб зрозуміти, що ми про неї стверджуємо. Через цю різницю звичні інженерні принципи в тестах працюють з поправками: те, що для продуктового коду безумовне благо, у тестах іноді шкодить. Найболючіший приклад — , який у наївному застосуванні перетворює прозорий тест на ребус.
Ця глава — про чотири орієнтири (DRY, , YAGNI і композицію), які тримають тестову кодову базу живою на дистанції в роки, а не тижні. Її можна пропустити при першому проході й повернутися, коли у вас уже є свій фреймворк і перші болі з його підтримки — саме тоді ці принципи з абстрактних гасел стають конкретними рішеннями на код-рев'ю. Механізми ООП (успадкування, поліморфізм) розібрані в главі ООП на тестовому коді; тут — про те, коли ними не варто зловживати.
DRY: не повторюйся — і що це насправді означає
DRY (Don't Repeat Yourself) — «не повторюйся». У першоджерелі (Дейв Томас і Енді Гант, «The Pragmatic Programmer») принцип сформульований так: «Every piece of knowledge must have a single, unambiguous, authoritative representation within a system».
Ключове слово тут — знання, а не текст, і це не наша інтерпретація: автори самі виправляють поширене прочитання. «Many people took it to refer to code only: they thought that DRY means "don't copy-and-paste lines of source."» — і одразу: «That is part of DRY, but it's a tiny and fairly trivial part». Що ж таке DRY насправді: «DRY is about the duplication of knowledge, of intent. It's about expressing the same thing in two different places, possibly in two totally different ways». Тобто дублювання може виглядати геть по-різному в двох місцях і все одно бути дублюванням.
Практичний критерій автори називають кислотним тестом: «when some single facet of the code has to change, do you find yourself making that change in multiple places, and in multiple different formats?». А ціну порушення формулюють як питання часу, не пам'яті: «It isn't a question of whether you'll remember: it's a question of when you'll forget».
У тестах справжнє дублювання знання виглядає так: логіка логіну розписана кроками у двадцяти тестах. Змінився флоу авторизації — і ви правите двадцять місць, гарантовано щось пропустивши. Ось це — правильна ціль для DRY: механіку дії виносять у хелпер, або метод , де вона живе в одному екземплярі.
// Дублювання ЗНАННЯ: як залогінитися — розмазано по тестах
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, а розставляє акценти: у тестовому коді читабельність важливіша за мінімізацію рядків. Тест мають розуміти локально, не гортаючи ієрархію абстракцій, бо його головна цінність — бути живою, виконуваною специфікацією поведінки.
Практичне правило межі просте:
- DRY на механіку — те як ми виконуємо дію (логін, підготовка даних, навігація) виносимо в одне джерело: хелпери, фікстури, методи page object.
- DAMP на намір — те що ми перевіряємо (arrange-дані сценарію і всі
expect) лишаємо в тілі тесту, навіть ціною повторів між тестами.
Трохи повторюваних expect у сусідніх тестах — це не борг, це читабельність. А от захований у спільному хелпері assert — майже завжди помилка: він змушує читача довіряти назві функції замість того, щоб бачити перевірку. Детальніше про структуру самого тесту — у главі Анатомія автотесту: AAA.
KISS: найпростіше рішення, що працює
KISS (Keep It Simple, Stupid) — вимога робити найпростіше рішення, яке розв'язує задачу, і не додавати складності без причини. Принцип старший за software: перша фіксація — ВМС США 1960 року, а фразу пов'язують з авіаінженером Келлі Джонсоном; суть формулюють коротко — «KISS implies that simplicity should be a design goal». Дрібниця, яку варто знати, щоб не сперечатися про літери: канонічної єдиної розшифровки другого слова немає, поряд із «stupid» ходять «keep it super simple», «keep it short and simple», «keep it simple and straightforward» та інші.
У тестовому коді це особливо гостро, бо тест, який сам складний, не викликає довіри: коли він падає, ви спершу підозрюєте не продукт, а власну хитромудру логіку. Одразу межа: перенос принципу на тестовий код — наш, у джерелі про тести не йдеться; підтвердження конкретних практик нижче стоїть на доці Playwright.
Симптоми порушення 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.
Здорове емпіричне правило — «правило трьох» (Rule of Three). Назву дає одне джерело — стаття про , у переліку порад про чистий тестовий код: «When in doubt use the Rule of Three to decide when to refactor». Тобто джерельним є сам орієнтир «коли рефакторити», а числове наповнення — вже наша практика: перший раз просто пишеш, другий — терпиш дублювання й помічаєш його, і лише на третьому подібному випадку виносиш спільне. Три реальні приклади показують справжню вісь варіативності; два — брешуть.
// Передчасно: узагальнення на основі ОДНОГО екрана
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). Класичне формулювання належить книзі «Design Patterns» (1994) — і «Gang of Four» (GoF) це усталена назва як самої книги, так і четвірки її авторів. Принцип звучить так: «Favor 'object composition' over 'class inheritance'». Аргумент авторів варто знати теж, бо він сильніший за саме гасло: за їхнім досвідом розробники успадкування переоцінюють, і «the implementation of a subclass can become so bound up with the implementation of its parent class that any change in the parent's implementation will force the subclass to change». Одна обмовка про джерельність: самої книги в нашому реєстрі немає (публічного повного тексту не існує), тож формулювання ми беремо з енциклопедичної статті про неї, яка цитує сторінку книги. У тестовому коді це правило працює ще жорсткіше, ніж у продуктовому.
Спокуса успадкування в тестах виглядає невинно: винесемо спільне в BaseTest, від нього — BaseUiTest, від нього — BaseAuthenticatedTest, і так далі. Кожен крок логічний, а через півроку виходить ланцюг із чотирьох рівнів, у якому неможливо відповісти на просте питання: «звідки в цьому тесті взявся залогінений юзер?».
Глибокі ієрархії болять із конкретних причин:
- (fragile base class). Це усталений термін ООП, і в означенні є частина, важливіша за саму крихкість: «The programmer cannot determine whether a base class change is safe simply by examining in isolation the methods of the base class». Тобто проблема не лікується уважністю — безпечність зміни не встановлюється оглядом самої бази. На практиці: ви поправили
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 assertion. — це відкладений . - Незалежність та ізоляція. Тест не спирається на дані, створені сусіднім тестом, і сам прибирає за собою. Спільний мутабельний стан між тестами — червоний .
- Передчасні абстракції. Новий «універсальний» базовий клас чи шар обгорток під один кейс — привід спитати «а це вже треба, чи YAGNI?».
- Свіжість даних. Хардкоджені id, email, дати, які зламаються завтра або на іншому середовищі (стратегія — у главі Тест-дані: фабрики, фікстури, ізоляція).
Корисна рев'ю-евристика: якщо, щоб зрозуміти один тест, доводиться відкрити більше двох-трьох інших файлів — абстракцій забагато. Тест має читатися майже як речення природною мовою.
Окремо про джерела теми: спеціального розділу в силабусі ISTQB CTFL 4.0 ці принципи не мають — це загальноінженерні практики. У контексті автоматизації їх варто читати разом із главами про SOLID і Page Object, а з боку канону тестування опору дає CTAL-TAE.
Типові помилки
- Виглядає як DRY, а насправді втрата читабельності. Винесли
assertу спільний хелпер «щоб не повторюватись» — і тепер при падінні незрозуміло, що саме зламалось. DRY стосується механіки дії, а не суті перевірки. - Виглядає як єдине джерело істини, а насправді хибне узагальнення. Два схожі шматки коду об'єднали в одну функцію, бо вони зараз однакові. Це збіг, а не спільне знання: щойно один зміниться, функція обросте прапорцями
if (isPremium). Однаковий вигляд — не привід для DRY; привід — однакове рішення. - Виглядає як передбачливість, а насправді YAGNI-порушення. «Фреймворк на виріст» із абстракціями під майбутні інструменти, яких у проєкті нема. Ви платите підтримкою вже, окупність — гіпотетична.
- Виглядає як гарна архітектура, а насправді ієрархія болю. Ланцюг
BaseTest → ... → MyTestна чотири рівні виглядає структуровано, але щоб зрозуміти один тест, треба прочитати всіх предків. Композиція через фікстури й компоненти читається локально. - Виглядає як is-a, а насправді has-a.
CheckoutTest extends LoginTest— але не є різновидом логіну, він ним користується. Успадкування заради перевикористання — класична пастка.
Підсумок
- DRY стосується дублювання знання (одного рішення в кількох місцях), а не візуально схожих рядків; у тестах його ціль — механіка дій, а не суть перевірок.
- Тести свідомо бувають DAMP: намір і асерти лишаються в тілі тесту заради читабельності, навіть ціною повторів між тестами.
- KISS і YAGNI у тестах — це довіра до інструмента (не переписуй руками) і від абстракцій, поки їх не попросили три реальні випадки.
- Композиція перемагає успадкування: глибокі ієрархії дають крихкий базовий клас, вертикальне читання й жорсткість; збирайте поведінку з фікстур і компонентів (has-a), а не з ланцюгів предків (is-a).
- Ці принципи живуть на код-рев'ю: тест має читатися локально, без стрибків по абстракціях, зі стабільними локаторами й без жорстких очікувань.
Можливі питання
- «DRY чи не DRY у тестах? Де межа?» Інтерв'юер перевіряє, чи розумієте ви, що тести — теж документація. Сильна відповідь: DRY — на механіку (логін, підготовка даних) через хелпери й фікстури; намір і асерти лишаємо в тесті (DAMP), бо його треба читати локально.
- «Композиція чи успадкування — що обрати для page object / базового тесту і чому?» Тут чекають на «надаю перевагу композиції» з причинами: крихкий базовий клас, глибина ланцюга, читабельність. Плюс приклад: компоненти всередині сторінки або фікстури Playwright замість
extends. - «Що не так із глибокою ієрархією BaseTest?» Очікують перелік болів: розповсюдження змін по нащадках, неможливість зрозуміти тест без предків, жорсткість комбінацій setup.
- «Наведіть приклад передчасної абстракції в автоматизації.» Перевіряють YAGNI на практиці: «фреймворк на виріст», обгортка над інструментом «щоб легко переїхати», узагальнення з одного кейса. Згадайте правило трьох.
- «На що дивитесь на рев'ю тестового коду?» Читабельність наміру, стабільні локатори, відсутність
waitForTimeout, незалежність тестів, свіжість даних. Це питання на зрілість, а не на знання синтаксису.
Джерела
DRY: не повторюйся — і що це насправді означає
- The Pragmatic Programmer, 20th Anniversary Edition — Topic 9: DRY — канонічне формулювання («Every piece of knowledge must have a single, unambiguous, authoritative representation within a system») і авторське виправлення поширеного прочитання: DRY про дублювання знання й наміру, а копіпаста — «a tiny and fairly trivial part».
- xUnit Test Patterns — Obscure Test — теза, яку розділ уживає як аксіому: тестовий код настільки ж важливий, як продакшн-код, і потребує рефакторингу так само часто.
- ISTQB® CTAL-TAE Syllabus v2.0 — «золоте правило» підтримуваності у формулюванні канону: чистий код, спільна конвенція іменування, логічна структура проєкту, уникати хардкоду й задовгих методів.
Межа DRY: чому тести бувають DAMP
- xUnit Test Patterns — Obscure Test — обидві крайності названі поіменно:
Eager TestіIrrelevant Information— це забагато інформації в методі,Mystery Guest— замало, коли підготовка спирається на невидиму в тесті інформацію. - Playwright — Best Practices — офіційна дока прямо дозволяє трохи дублювання в простих тестах, якщо це робить їх зрозумілішими й легшими в підтримці.
- Martin Fowler — TestCoverage — симптом того, що дублювання таки зайве: проста зміна коду тягне надмірно довгі зміни тестів (
trust: secondary, dec-0311).
KISS: найпростіше рішення, що працює
- Wikipedia — KISS principle — принципу (ВМС США, 1960) і те, що єдиної канонічної розшифровки другого слова немає — джерело перелічує варіанти (
trust: secondary, dec-0710). - ISTQB® CTAL-TAE Syllabus v2.0 — бік канону тестування: уникати задовгих методів і задовгих списків параметрів, вживати патерни лише там, де вони корисні.
- Martin Fowler — Yagni — предмет принципу названо точно: , тобто код під можливість, ще не віддану в користування; чотири види витрат (побудова, відкладена цінність, носіння, ремонт), критерій «абстракція, що ускладнює розуміння коду під поточні вимоги, вважається винною, доки не доведено протилежне» — і два обмежувачі, які найчастіше перекручують: рефакторинг порушенням не є, а «YAGNI не є виправданням нехтувати здоровʼям кодової бази».
- The Practical Test Pyramid (Ham Vocke) — перелік «Writing Clean Test Code»:
Rule of Threeназваний саме як орієнтир, коли рефакторити, поряд із межею DRY/DAMP («дублювання прийнятне, якщо покращує читабельність»). Числа правила джерело не дає — «на третьому подібному випадку» лишається нашою практикою.
- Wikipedia — Design Patterns (Gang of Four) — джерело самого формулювання «Favor 'object composition' over 'class inheritance'» з атрибуцією сторінки книги; аргумент авторів — успадкування переоцінюють (
trust: secondary, dec-0710). - Wikipedia — Fragile base class — чому глибока ієрархія небезпечна: безпечна на вигляд зміна бази ламає нащадків, і безпечність зміни не встановлюється оглядом самого базового класу; серед названих ліків — інтерфейс замість суперкласу (
trust: secondary, dec-0710). - Playwright — Test fixtures — як композиція виглядає в : тест дістає рівно те, що просить у сигнатурі, а фікстури складаються одна з одної без спільного предка.
- ISTQB® CTAL-TAE Syllabus v2.0 — що саме канон вимагає від тестового коду на рев'ю: статичні аналізатори й форматери, спільна конвенція іменування, узгоджена стратегія , зрозумілі імена змінних.
- xUnit Test Patterns — Obscure Test — ціна пропущеного рев'ю названа подвійною: дорога підтримка і пропущені баги через помилки в самому тесті.
Відео до теми
Що таке принцип DRY і чому «повторення» в ньому стосується не схожих рядків?
(Don't Repeat Yourself) у першоджерелі звучить так: «Every piece of knowledge must have a single, unambiguous, authoritative representation within a system». Ключове слово тут — знання, а не текст, і автори наголошують це самі: «Many people took it to refer to code only… That is part of DRY, but it's a tiny and fairly trivial part», бо насправді «DRY is about the duplication of knowledge, of intent». Принцип цілиться в дублювання рішення (бізнес-правила чи способу виконати дію), а не візуально однакових рядків — і дублювання може бути виражене «in two totally different ways». Небезпека справжнього дубля в тому, що одного дня його поправлять в одному місці й забудуть в іншому — і система стане суперечливою. У тестах хрестоматійний приклад — покрокова логіка логіну, розписана в десятках тестів: варто змінитися формі входу, і треба редагувати кожну копію. А от два фрагменти, які просто зараз схожі, але концептуально незалежні, дублем знання не є — об'єднувати їх не обов'язково.
Чому тестовий код інколи навмисно роблять DAMP, а не максимально DRY?
Бо тест — це ще й документація: його читають, щоб зрозуміти, що ми стверджуємо про систему. Принцип DAMP (його зазвичай розкривають як Descriptive And Meaningful Phrases) ставить читабельність вище за мінімізацію рядків. Якщо гнатися за DRY і ховати суть перевірки під шар абстракцій, тест вироджується в набір викликів -хелперів: коли він падає, незрозуміло, що саме зламалось, без розкручування всього клубка. DAMP не заперечує DRY, а розставляє акценти — намір і видно локально, прямо в тілі тесту. Невеликий дубль expect між сусідніми тестами тут прийнятна ціна за те, що тест читається як специфікація поведінки.
Де проходить межа: що виносити у спільне, а що лишати в тілі тесту?
Робоча евристика формулюється парою: DRY на механіку, DAMP на намір. Механіка — це як ми виконуємо дію: логін, підготовка даних, навігація; її виносять в одне джерело — хелпер, або метод . Намір — це що ми перевіряємо: arrange-дані конкретного сценарію і всі expect; вони лишаються в тілі тесту, навіть ціною повторів між тестами. Тобто спільною стає рутина, а унікальним — твердження. Простий критерій, що межу проведено правильно: читаючи тест, ви розумієте, що він перевіряє, не відкриваючи інші файли.
Чому винесення expect у спільний хелпер — майже завжди помилка?
Тому що перевірка і є суттю тесту, а сховати її означає змусити читача довіряти назві функції замість того, щоб бачити саме твердження. Коли умовний assertHappyPath(page) падає, ви не знаєте, який конкретно expect усередині червоний, поки не відкриєте хелпер, — а це прямо суперечить ролі тесту як документації. Механіку дії ховати можна й треба, а суть перевірки — ні: вона має лишатися перед очима. Єдиний розумний виняток — тонкі доменні матчери, які роблять асерт виразнішим, а не ховають його.
Що таке KISS і як він проявляється в автотестах?
(Keep It Simple, Stupid) — вимога брати найпростіше рішення, яке розв'язує задачу, і не додавати складності без причини; коротка форма — «simplicity should be a design goal». Принцип старший за software: перша фіксація — ВМС США 1960 року, фразу пов'язують з авіаінженером Келлі Джонсоном, а єдиної канонічної розшифровки другого слова немає. У тестах це особливо гостро: складний тест не викликає довіри, бо коли він червоніє, першою підозрюють не продукт, а власну хитромудру логіку. Часто KISS зводиться до «довіряй інструменту» — якщо фреймворк уже вміє чекати, ретраїти асерт чи ізолювати контекст, не переписуй це руками. Простіший тест водночас і надійніший, і зрозуміліший, і дешевший у підтримці.
Наведіть приклад порушення KISS у Playwright-тесті.
Найтиповіший — ручний polling там, де є web-first assertion. Замість циклу з waitForTimeout(500) і власним прапорцем, який чекає появи елемента, достатньо await expect(locator).toBeVisible(): воно саме перечікує й ретраїть до . Інші впізнавані симптоми: обгортка над expect, яка «покращує» повідомлення, а насправді ховає стек; регулярка на десять груп там, де вистачає toContainText; генерація даних циклом з розгалуженнями замість трьох явних рядків. Спільне в усіх випадках — рукотворна складність поверх того, що інструмент уже вміє сам.
Що таке YAGNI і чому передчасні абстракції в тестах дорожчі, ніж у продукті?
YAGNI (You Aren't Gonna Need It) — принцип з екстремального програмування: не будуй функціональність, поки вона реально не знадобилась. У тестовій автоматизації його порушують на старті, коли є десять тестів, а вже пишеться «універсальний фреймворк на виріст» — базові класи й фабрики драйверів під те, чого в проєкті немає. Ціна висока з двох причин. По-перше, за абстракцію ви платите підтримкою вже сьогодні, а окупність гіпотетична й у майбутньому, яке може не настати. По-друге — і це специфіка тестів — форму абстракції, витягнутої з одного-двох випадків, майже завжди вгадують неправильно: третій реальний кейс у неї не влізе, і доведеться або ламати її, або натягувати кейс на неї прапорцями.
Що таке «правило трьох» і навіщо воно?
Це емпіричне правило, яке підказує момент для узагальнення. Саму назву — Rule of Three — дає одне джерело, стаття про , і саме як орієнтир, коли рефакторити; числове наповнення вже наше: перший раз просто пиши, другий — стерпи дубль і поміть його, і лише на третьому подібному випадку виноси спільне. Сенс у тому, що два приклади брешуть про вісь варіативності, а три вже показують її по-справжньому — видно, що насправді спільне, а що відрізняється. Це протиотрута і від передчасних абстракцій (винесли зарано — вгадали форму неправильно), і від сліпого DRY. Правило не догма, а нагадування зачекати з узагальненням, поки не набереться достатньо доказів.
У чому різниця між композицією (has-a) і успадкуванням (is-a)?
Успадкування — це відношення «є» (is-a): клас-нащадок є різновидом базового й дістає його поведінку через ланцюг предків. Композиція — відношення «має» (has-a): об'єкт збирає потрібну поведінку з окремих частин, які містить у собі. Канонічна порада книги «Design Patterns» (1994), авторів якої усталено називають «бандою чотирьох» (, GoF), — «Favor 'object composition' over 'class inheritance'»; аргумент авторів у тому, що успадкування зазвичай переоцінюють, а реалізація нащадка зростається з реалізацією предка. Практичний тест на вибір: якщо між класами справді відношення «є» і ланцюг неглибокий — успадкування доречне; якщо ви успадковуєте лише щоб перевикористати код, це насправді «має», натягнуте на «є».
Чому в тестовому коді композицію віддають перевагу особливо жорстко?
Тому що тести читають поодинці, а глибокі ієрархії роблять це неможливим. Спокуса виглядає невинно: спільне виносимо в BaseTest, від нього — BaseUiTest, далі — BaseAuthTest; кожен крок логічний, а через півроку маєш ланцюг на чотири рівні, у якому не відповіси на просте «звідки в цьому тесті взявся залогінений юзер». Композиція через фікстури й компоненти лишає це видимим: тест явно оголошує, що йому треба, і поведінка збирається локально. До того ж успадкування фіксує комбінацію під час написання класу, а фікстури дають зібрати рівно те, що потрібно конкретному тесту.
Що таке «крихкий базовий клас» (fragile base class)?
Це усталений термін ООП, і в означенні є частина, важливіша за саму крихкість: «The programmer cannot determine whether a base class change is safe simply by examining in isolation the methods of the 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 без автологіну; будь-яка зміна в LoginTest б'є по всіх нащадках. Правильно винести логін у фікстуру чи хелпер — і тест checkout просто отримає залогінену сесію як здатність, а не успадкує її разом з усім рештою.
Три ситуації з рев'ю, де ці принципи перестають бути гаслами й стають конкретним рішенням на код: над- тест, який осліп, і повернення його в 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');
});
Той самий перехід — усередині : сторінка не успадковує хедер, а містить компоненти як поля.
// 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 на механіку (логін, підготовка даних, навігація), DAMP на намір (arrange-дані сценарію й усі
expect). - Можу пояснити, що таке DAMP і чому в тестах читабельність важливіша за мінімізацію рядків.
- Знаю, чому винесення
expectу спільний хелпер ховає суть перевірки й ускладнює діагностику падіння. - Можу відрізнити справжній дубль (однакове рішення) від випадкового збігу двох незалежних фрагментів.
KISS
- Розумію як «найпростіше рішення, що працює» (простота як мета дизайну) і чому складний тест сам підриває довіру до себе; знаю, що застосування принципу до тестового коду — наш перенос, а не текст джерела.
- Знаю, що KISS у тестах часто означає «довіряй інструменту»: web-first assertion замість ручного polling.
- Можу назвати симптоми порушення KISS: обгортка над
expect, зайва регулярка, рукотворнийretry, генерація даних циклом із розгалуженнями.
YAGNI і передчасні абстракції
- Розумію YAGNI: не будувати функціональність, поки вона реально не знадобилась.
- Можу пояснити, чому передчасна абстракція в тестах дорожча — платиш підтримкою вже, а форму вгадуєш з одного-двох кейсів неправильно.
- Знаю «правило трьох» (Rule of Three): джерельним є сам орієнтир «коли рефакторити», а числове формулювання — наша практика: виносити спільне лише на третьому подібному випадку, бо два приклади брешуть про вісь варіативності.
- Можу навести приклад передчасної абстракції: «фреймворк на виріст», шар над Playwright «щоб легко переїхати», базовий клас під один екран.
Композиція проти успадкування
- Знаю різницю has-a (композиція, «має») vs is-a (успадкування, «є») і правило «надавай перевагу композиції над успадкуванням».
- Можу перелічити болі глибокої ієрархії: (безпечність зміни не встановлюється оглядом самої бази), вертикальне читання, жорсткість комбінацій, успадкування заради .
- Розумію, як Playwright (
test.extend) реалізують композицію — тест явно оголошує потрібні здатності в сигнатурі. - Знаю, що композиція в — це компоненти (
header,cart) як поля, а не ланцюгextends. - Можу сказати, коли успадкування виправдане: справжнє «є» плюс неглибокий ланцюг (тонкий
BaseComponentз полемpage).
Код-рев'ю тестового коду
- Знаю, чим рев'ю тестів відрізняється від продуктового: головне — читабельність наміру й стійкість до редизайну, а не ефективність.
- Можу перелічити, на що дивитись: намір без стрибків у хелпери, стабільні , відсутність
waitForTimeout, незалежність та ізоляція, свіжість даних, відсутність передчасних абстракцій. - Пам'ятаю евристику: якщо для розуміння одного тесту треба відкрити більше двох-трьох файлів — абстракцій забагато.
Квіз
Перед стартом
- Питань: 13
- Поріг «зараховано»: ≥70% правильних відповідей.
- Результат впливає на прогрес; завалені питання підуть у чергу повторення.
- Квіз впливає на компліт теми: тема стає «пройдено», лише коли прочитано теорію І квіз складено на ≥70%.
Питання
DRY стосується дублювання чого?


