vyvchy
    Теми розділу

    06 · Автоматизація: стратегія

    Принципи дизайну тестового коду: 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, і так далі. Кожен крок логічний, а через півроку виходить ланцюг із чотирьох рівнів, у якому неможливо відповісти на просте питання: «звідки в цьому тесті взявся залогінений юзер?».

    Композиція: набір «має»

    CheckoutTest

    має: api-клієнт

    має: браузер-контекст

    має: сесію юзера

    Успадкування: ланцюг «є»

    BaseTest
    setup/teardown

    BaseApiTest
    + http-клієнт

    BaseUiTest
    + браузер

    BaseAuthTest
    + логін

    CheckoutTest

    Композиція: набір «має»

    CheckoutTest

    має: api-клієнт

    має: браузер-контекст

    має: сесію юзера

    Успадкування: ланцюг «є»

    BaseTest
    setup/teardown

    BaseApiTest
    + http-клієнт

    BaseUiTest
    + браузер

    BaseAuthTest
    + логін

    CheckoutTest

    Глибокі ієрархії болять із конкретних причин:

    • (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 — бік канону тестування: уникати задовгих методів і задовгих списків параметрів, вживати патерни лише там, де вони корисні.

    YAGNI і передчасні абстракції

    • 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 — ціна пропущеного рев'ю названа подвійною: дорога підтримка і пропущені баги через помилки в самому тесті.

    Пояснення

    «Поясни» працює з власним API-ключем Anthropic: запит іде з вашого браузера прямо до Anthropic.

    Свій API-ключ (BYOK)

    Вставте власний ключ Anthropic — пояснення працюватиме на реальній моделі. Ключ зберігається лише у вашому браузері (localStorage), нікуди не надсилається, крім api.anthropic.com, і не логується.