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

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

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

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

    CheckoutTest

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

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

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

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

    BaseTest
    setup/teardown

    BaseApiTest
    + http-клієнт

    BaseUiTest
    + браузер

    BaseAuthTest
    + логін

    CheckoutTest

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

    CheckoutTest

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

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

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

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

    BaseTest
    setup/teardown

    BaseApiTest
    + http-клієнт

    BaseUiTest
    + браузер

    BaseAuthTest
    + логін

    CheckoutTest

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

    • Крихкий базовий клас (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.