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

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

    Анатомія автотесту: AAA, незалежність, атомарність

    Зміст

    Зелений тест сам по собі не вартий нічого — цінність має тест, якому можна вірити. А довіра народжується не з магії фреймворка, а зі структури: коли тест падає, ти маєш за секунди зрозуміти, що саме зламалося, не запускаючи його вдесяте й не читаючи двісті рядків. Погано влаштований тест — це той, що падає з повідомленням «element not found», а справжня причина за три екрани вище, у чужому тесті, який не прибрав за собою.

    Тому «анатомія автотесту» — не естетика й не педантизм. Це набір інженерних інваріантів, які роблять фейл діагностованим, а сьюту — придатною до паралельного запуску й до життя роками. На співбесіді на AQA це стабільна пара питань: «розкажіть про AAA» і «чому тести мають бути незалежними». Відповідь показує, чи писали ви тести, які реально підтримували, чи лише «щоб проходили».

    Три фази: Arrange–Act–Assert

    Arrange–Act–Assert (AAA) — це шаблон структури тіла тесту в три фази: підготувати (arrange), виконати цільову дію (act), перевірити наслідок (assert). Ідея стара, з ери юніт-тестів: споріднену модель Джерард Мезарос (Gerard Meszaros) описує як чотирифазний тест (four-phase test) — setup (≈arrange), exercise (≈act), verify (≈assert) і додаткова teardown для прибирання. Спільну підготовку й прибирання зазвичай виносять у фікстури (fixtures) й хуки (див. Тестовий стек зсередини).

    Setup
    фікстура / хук

    Arrange
    дані й стан

    Act
    одна цільова дія

    Assert
    перевірка наслідку

    Teardown
    прибрати за собою

    Setup
    фікстура / хук

    Arrange
    дані й стан

    Act
    одна цільова дія

    Assert
    перевірка наслідку

    Teardown
    прибрати за собою

    Навіщо ця дисципліна? Бо вона робить намір тесту читабельним і локалізує помилку. Коли фази перемішані — клік, перевірка, знову клік, знову перевірка, десь посередині ще налаштування — читач не може відповісти на просте питання: «що взагалі перевіряє цей тест?». А коли впаде, ти не знатимеш, чи то підготовка не спрацювала, чи сама поведінка зламалася.

    test('кошик показує кількість доданих товарів', async ({ page }) => {
      // Arrange
      await page.goto('/catalog');
    
      // Act
      await page.getByRole('button', { name: 'Додати в кошик' }).first().click();
    
      // Assert
      await expect(page.getByTestId('cart-badge')).toHaveText('1');
    });

    Одне цільове Act — ключовий момент. Якщо у фазі дії три кліки й дві навігації, тест перевіряє вже не одну поведінку, а сценарій, і причин впасти стає багато. Споріднений формат — Given–When–Then з BDD: те саме розбиття, але словами домену (мета — жива документація, а не структура коду; див. BDD і Gherkin).

    Одна причина впасти: атомарність

    Атомарність (atomicity) тесту — це властивість «одна причина впасти». Тест перевіряє один інваріант (invariant) — одну властивість системи, яка має справджуватися, — і якщо він червоний, причина очевидна з назви.

    Тут живе поширений міф, який варто одразу зняти: атомарність — це не «рівно один assert на тест». Перевірити один інваріант часто означає кілька асертів, які разом описують один стан:

    test('після оплати замовлення переходить у статус paid', async ({ page }) => {
      // ... Arrange + Act ...
    
      // усе це — про ОДИН інваріант: «замовлення оплачене»
      await expect(page.getByTestId('order-status')).toHaveText('Оплачено');
      await expect(page.getByTestId('pay-button')).toBeHidden();
      await expect(page.getByTestId('receipt-link')).toBeVisible();
    });

    Антипатерн — не кількість асертів, а кількість не повʼязаних причин. Коли один тест логіниться, створює товар, редагує профіль і перевіряє чек-аут — він упаде з десятка різних причин, і кожен фейл вимагатиме розслідування «а на якому саме кроці?». Розбийте такий тест на кілька атомарних: логін окремо, створення окремо, чек-аут окремо. Бонус — вони зможуть бігти паралельно.

    Атомарність напряму годує ще один інструмент — hard vs soft перевірки (нижче): у більшості випадків тест має падати на першій же розбіжності, бо решта асертів у зламаному стані все одно неінформативні.

    Незалежність: тест не має памʼятати про сусідів

    Незалежність (independence) означає: результат тесту не залежить ні від інших тестів, ні від порядку їх запуску. Перемішайте сьюту випадково, виконайте будь-який тест окремо, запустіть у десять воркерів — вердикт кожного має лишитися тим самим.

    Чому це критично саме зараз: сучасні ранери бігають паралельно (див. Паралелізація), і порядок виконання ти не контролюєш. Тест, який мовчки покладається на дані, створені попереднім тестом, у паралельному прогоні впаде випадковим чином — це класичний флакі-тест.

    Незалежні: кожен готує свій стан

    test A: свій товар

    test B: свій товар

    test C: свій товар

    Зчеплені: падають від порядку

    test A: створює товар

    test B: очікує товар від A

    test C: видаляє товар

    Незалежні: кожен готує свій стан

    test A: свій товар

    test B: свій товар

    test C: свій товар

    Зчеплені: падають від порядку

    test A: створює товар

    test B: очікує товар від A

    test C: видаляє товар

    У зчепленому варіанті досить, щоб test A впав або щоб ранер переставив тести — і test B червоний не через баг, а через сусіда. У незалежному кожен тест сам готує собі стан і сам його прибирає.

    Джерела зчеплення, яких треба уникати: спільні змінні на рівні файлу, порядок як прихована умова, і — найпідступніше — спільний стан у застосунку (один тестовий користувач на всіх, спільний запис у БД). Playwright дає ізоляцію майже даром: кожен тест отримує свіжий browser context — окремий профіль без кук і localStorage попереднього тесту. Але ізоляція браузера не рятує від спільного стану на бекенді: якщо два паралельні тести правлять того самого юзера, конфлікт неминучий (детально — Тест-дані).

    Підготовка стану через API або БД

    Найпоширеніша помилка новачка в Arrange — готувати стан через той самий UI, що й тестуємо. Щоб перевірити сторінку профілю, тест проходить реєстрацію через форму, підтверджує email, логіниться — двадцять кроків, кожен з яких може моргнути й завалити тест, який насправді про профіль.

    Правило: готуй стан найдешевшим і найнадійнішим каналом, а не через UI. Зазвичай це прямий виклик API, рідше — запис у базу. Чому:

    • Швидкість. Один POST-запит замість десятка кліків з очікуваннями.
    • Стабільність. Підготовка не залежить від верстки, анімацій і локаторів чужих екранів.
    • Чесність фейлу. Якщо реєстрація через API успішна, а тест профілю впав — виною профіль, а не setup. UI-підготовка змішує ці причини й порушує атомарність.
    test('користувач бачить своє імʼя в профілі', async ({ page, request }) => {
      // Arrange: стан через API, а не через UI-реєстрацію
      const res = await request.post('/api/users', {
        data: { email: `u+${Date.now()}@example.com`, name: 'Ihor' },
      });
      const { id, token } = await res.json();
    
      // Act
      await page.goto(`/profile/${id}?token=${token}`);
    
      // Assert
      await expect(page.getByRole('heading', { level: 1 })).toHaveText('Ihor');
    });

    Виняток лише один: коли перевіряєш саме UI-флоу підготовки — тоді реєстрація через форму є предметом тесту, і скорочувати її не можна. У всіх інших випадках UI-шлях у Arrange — це технічний борг, який колись стане флаком. Сідінг через API vs напряму в БД — окремий вибір: API поважає бізнес-правила й тригери, прямий запис у БД швидший, але легко лишає систему в неможливому стані (детальніше в темах про API-тестування й SQL).

    Hard проти soft перевірок

    За замовчуванням перевірки жорсткі (hard): перший невдалий expect кидає виняток і зупиняє тест. Це правильний дефолт — після зламаного інваріанту решта перевірок часто в непередбачуваному стані, і їхній вердикт лише шумить.

    Мʼякі (soft) перевірки не зупиняють тест: усі розбіжності збираються, тест доходить до кінця й падає наприкінці зі списком усього, що не так. У Playwright це expect.soft(...):

    test('картка товару відрендерена повністю', async ({ page }) => {
      await page.goto('/product/42');
    
      await expect.soft(page.getByTestId('title')).toHaveText('Термокухоль');
      await expect.soft(page.getByTestId('price')).toHaveText('₴499');
      await expect.soft(page.getByTestId('stock')).toHaveText('В наявності');
    });

    Коли що: hard — коли наступні кроки не мають сенсу без попереднього (немає кнопки «Оплатити» — нема що й клікати далі). soft — коли перевіряєш незалежні аспекти одного стану й хочеш побачити всі розбіжності за один прогін, а не лагодити їх по одній. Класичний кейс для soft — валідація багатьох полів відрендереної сторінки чи тіла API-відповіді.

    Пастка soft: після нього тест продовжується, тож якщо далі йде дія, що залежить від невдалої перевірки, отримаєш незрозумілий каскадний фейл. Soft хороший наприкінці фази Assert, а не посеред взаємозалежних кроків.

    Назва описує інваріант, а не дію

    Назва тесту — перше, що ти бачиш у червоному звіті, часто взагалі без коду поруч. Тому вона має відповідати на питання «яку властивість системи ми втратили?», а не переказувати кроки.

    Погана назваЧому поганоДобра назва
    test('клік по кнопці')Описує дію, не результатгість не може видалити чужий коментар
    test('перевірка форми')Ні предмета, ні очікуванняформа логіну відхиляє невалідний email
    test('тест 3')Нічого не говоритьпорожній кошик показує заглушку

    Механіка проста: у назву виноситься інваріант (що має бути істинним), а кроки лишаються у тілі, у фазі Act. «Клік» — це деталь реалізації; завтра кнопку замінять на свайп, а інваріант «гість не видаляє чуже» лишиться. Хороший маркер: якщо назву можна прочитати як речення специфікації без слова «тест» — вона правильна.

    Тест як документація

    Складіть докупи AAA, атомарність і назву-інваріант — і тест починає працювати як жива документація поведінки. Новий інженер відкриває сьюту й читає її як перелік гарантій продукту: «гість бачить заглушку порожнього кошика», «оплата переводить замовлення в paid», «невалідний email відхиляється». Це документація, яка не бреше, бо CI не дасть їй розійтися з кодом.

    Декларативний стиль підсилює ефект — коли тест говорить що, а не як. У CodeceptJS сценарний синтаксис читається майже як текст вимоги:

    Scenario('гість не бачить кнопку видалення статті', ({ I }) => {
      I.amOnPage('/article/10');        // Arrange
      I.dontSeeElement('[data-testid="delete-article"]'); // Assert
    });

    Умова, щоб це працювало: назви — інваріантами, тіло — коротким і на одному рівні абстракції, а деталі локаторів заховані в page object (див. Page Object) чи кроки. Тест, у якому впереміш CSS-селектори, sleep і бізнес-логіка, документацією не стане — його самого треба документувати.

    Типові помилки

    • Виглядає як «один assert = атомарність», а насправді атомарність — це «одна причина впасти». Розбивати повʼязані перевірки одного стану на окремі тести зайве; шкідливо змішувати не повʼязані причини в один тест.
    • Виглядає як незалежний тест, а насправді він тримається на сусідові. Немає явного порядку в коді, але test B очікує дані, які створив test A. Локально й послідовно зелено; у паралелі чи при перемішуванні — випадковий флак.
    • Виглядає як «підготували дані», а насправді протестували UI-реєстрацію двадцятий раз. Setup через ту саму форму робить кожен тест заручником чужого екрана й ховає справжню причину фейлу.
    • Виглядає як ретельна перевірка, а насправді каскад від soft. Після невдалого expect.soft тест іде далі й падає на дії, що залежала від зламаного стану, — репорт вказує не туди.
    • Виглядає як зайвий teardown, а насправді витік стану. Тест створив запис і не прибрав — наступний прогін ловить дубль або конфлікт унікальності. Прибирання за собою — частина незалежності, а не формальність.
    • Виглядає як «тест проходить, отже все добре», а насправді назва бреше. test('перевірка кошика') зелений, але що саме він гарантує — не скаже ніхто, і регресію він пропустить мовчки.

    Підсумок

    • AAA розбиває тіло тесту на підготовку, одну цільову дію й перевірку наслідку — це робить намір читабельним, а фейл локалізованим.
    • Атомарність — «одна причина впасти», а не «один assert»: кілька асертів про один інваріант нормальні, кілька не повʼязаних причин в одному тесті — ні.
    • Незалежність від порядку — передумова паралельного запуску; спільний стан (на бекенді, не лише в браузері) — головне джерело флаку.
    • Стан у Arrange готуй через API чи БД, а не через UI, — крім випадку, коли сам UI-флоу і є предметом тесту.
    • Hard — дефолт (падати на першій розбіжності); soft — для незалежних перевірок одного стану наприкінці Assert. Назва описує інваріант, і тоді сьюта читається як жива документація.

    Що питають на співбесіді

    • «Що таке AAA і навіщо?» — інтервʼюер перевіряє, чи розумієте ви, що структура потрібна для діагностованості фейлу, а не для краси. Слабка відповідь переказує три слова; сильна додає «одне цільове Act» і згадує setup/teardown у фікстурах.
    • «Чому тести мають бути незалежними? Що станеться, якщо ні?» — очікують звʼязок із паралелізацією й флаком: у паралельному прогоні порядок недетермінований, тож зчеплені тести падають випадково. Плюс приклад джерела зчеплення (спільний користувач, дані від сусіда).
    • «Скільки asserts має бути в тесті — один чи багато?» — пастка на догму. Правильно — «стільки, скільки описують один інваріант; погано не багато асертів, а багато не повʼязаних причин впасти».
    • «Як готуєте тестові дані — через UI чи інакше? Чому?» — дивляться, чи знаєте ви про підготовку через API/БД і чому вона надійніша й швидша, і чи бачите виняток (коли UI-флоу — сам предмет тесту).
    • «hard vs soft assertions — коли що?» — очікують не лише синтаксис expect.soft, а критерій вибору: залежні кроки → hard, незалежні аспекти одного стану → soft, і пастку каскадного фейлу після soft.
    • «Як назвати тест?» — сигнал зрілості: назва як інваріант/речення специфікації, а не переказ кліків.

    Джерела