Анатомія автотесту: 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) й хуки (див. Тестовий стек зсередини).
Навіщо ця дисципліна? Бо вона робить намір тесту читабельним і локалізує помилку. Коли фази перемішані — клік, перевірка, знову клік, знову перевірка, десь посередині ще налаштування — читач не може відповісти на просте питання: «що взагалі перевіряє цей тест?». А коли впаде, ти не знатимеш, чи то підготовка не спрацювала, чи сама поведінка зламалася.
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 червоний не через баг, а через сусіда. У незалежному кожен тест сам готує собі стан і сам його прибирає.
Джерела зчеплення, яких треба уникати: спільні змінні на рівні файлу, порядок як прихована умова, і — найпідступніше — спільний стан у застосунку (один тестовий користувач на всіх, спільний запис у БД). 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. - «Як назвати тест?» — сигнал зрілості: назва як інваріант/речення специфікації, а не переказ кліків.
Джерела
- ISTQB Certified Tester Foundation Level (CTFL) 4.0 Syllabus — розділи про тестове забезпечення (testware) і його підтримуваність; термінологія тест-кейсу й незалежності перевірок.
- xUnit Test Patterns — Four-Phase Test (G. Meszaros) — канонічний опис структури setup–exercise–verify–teardown, з якої виріс AAA.
- Playwright — Test isolation — як
browser contextдає ізоляцію стану між тестами. - Playwright — Assertions (soft assertions) —
expectvsexpect.softі поведінка ретраїв. - Playwright — Best Practices — повна ізоляція й незалежність тестів, стабільні user-facing локатори, web-first асерти.
Що таке AAA і навіщо розбивати тіло тесту на фази?
Arrange–Act–Assert — це домовленість тримати тіло тесту в трьох послідовних блоках: підготувати умови (arrange), зробити цільову дію (act), звірити наслідок (assert). Сенс не в естетиці, а в діагностованості: коли фази не перемішані, будь-який читач за секунду розуміє намір тесту й одразу бачить, де саме він упав — на підготовці чи на самій поведінці. Тест, у якому клік, перевірка, ще налаштування й знову клік ідуть упереміш, на питання «що він взагалі гарантує?» відповіді не дає. Історично шаблон виріс із юніт-тестів; спорідненою є чотирифазна модель Мезароса з окремим прибиранням (teardown). На співбесіді слабка відповідь просто переказує три слова, сильна додає «одне цільове Act» і згадує, що setup/teardown зазвичай виносять у фікстури.
Що означає «одне цільове Act» і чому це важливо?
Фаза дії має містити рівно одну поведінку, яку тест перевіряє, — один клік, один запит, одну відправку форми. Щойно в Act опиняється три кліки й дві навігації, тест перевіряє вже не поведінку, а цілий сценарій, і причин впасти в нього стає багато. Тоді червоний результат не локалізує проблему: незрозуміло, який саме з кроків зламався. Кілька дій підряд — це ознака, що тест варто розділити на кілька, кожен зі своїм вузьким Act. Виняток — коли послідовність кроків і є предметом перевірки (наприклад, наскрізний флоу оформлення), але й там кожен крок бажано покривати ще й окремим атомарним тестом.
Скільки assert-ів має бути в тесті — один чи багато?
Це питання-пастка на догму «один assert на тест». Правильна відповідь — стільки, скільки потрібно, щоб описати один інваріант. Перевірка стану «замовлення оплачене» цілком може складатися з трьох асертів: статус став «Оплачено», кнопка оплати зникла, зʼявилося посилання на чек — усі троє про один і той самий факт. Погано не багато асертів, а багато не повʼязаних між собою причин впасти в одному тесті. Тому критерій — не рахувати expect, а питати: чи всі ці перевірки про одну властивість системи? Якщо так — вони на своєму місці.
Що таке атомарність тесту?
Атомарність — це властивість «одна причина впасти»: тест перевіряє один інваріант, і якщо він червоний, причина зрозуміла вже з назви, без запуску дебагера. Антипатерн — тест, який логіниться, створює товар, редагує профіль і перевіряє чек-аут: він упаде з десятка різних причин, і кожен фейл вимагатиме окремого розслідування «а на якому кроці зламалося?». Ліки — розбити такий тест на кілька атомарних: логін окремо, створення окремо, чек-аут окремо. Бонус атомарності — такі тести можна ганяти паралельно, бо вони не залежать один від одного. Важливо не плутати атомарність із кількістю асертів: атомарність про причини, а не про рядки.
Чому автотести мають бути незалежними й що станеться, якщо ні?
Незалежність означає, що вердикт кожного тесту не залежить ні від інших тестів, ні від порядку їхнього запуску: перемішай сьюту, запусти будь-який тест окремо чи розкидай на десять воркерів — результат має лишитися тим самим. Критично це саме зараз, бо сучасні ранери виконують тести паралельно, і порядок ти не контролюєш. Тест, який мовчки розраховує на дані від попереднього, у паралельному прогоні впаде випадковим чином — це класичне джерело флаку. У зчепленому наборі досить, щоб перший тест упав або щоб ранер переставив тести, — і другий червоний не через баг продукту, а через сусіда. У незалежному кожен тест сам готує собі стан і сам прибирає після себе.
Які бувають джерела зчеплення між тестами?
Найпоширеніші — спільні змінні на рівні файлу, куди один тест щось кладе, а інший читає; неявна залежність від порядку виконання; і найпідступніше — спільний стан на бекенді: один тестовий користувач на всіх, спільний запис у базі. Останнє особливо небезпечне, бо ізоляція браузера тут не рятує: два паралельні тести, які правлять того самого юзера, конфліктуватимуть незалежно від чистоти контексту. Ще одне тихе джерело — тест, що створив запис і не прибрав його: наступний прогін ловить дубль або порушення унікальності. Правило просте: кожен тест готує власні дані (унікальні на прогін) і сам їх прибирає.
Чи достатньо ізоляції browser context у Playwright для незалежності?
Ні, вона розвʼязує лише половину проблеми. Playwright дає кожному тесту свіжий browser context — окремий профіль без кук і localStorage попереднього тесту, тож клієнтський стан браузера ізольований майже даром. Але це ізоляція клієнта, а не бекенду: якщо два паралельні тести пишуть в один запис бази чи ділять одного тестового користувача, конфлікт неминучий, і жоден свіжий контекст його не прибере. Тому незалежність на рівні даних доводиться забезпечувати окремо — унікальними сутностями на кожен тест і прибиранням після себе. Сильна відповідь чітко розводить ці два рівні: браузерна ізоляція проти ізоляції стану на сервері.
Як правильно готувати тестові дані — через UI чи інакше?
Стан у фазі Arrange треба готувати найдешевшим і найнадійнішим каналом, а це майже завжди прямий виклик API, рідше — запис у базу, а не проходження того самого UI, який ми тестуємо. Причин три: швидкість (один POST замість десятка кліків з очікуваннями), стабільність (підготовка не залежить від верстки, анімацій і локаторів чужих екранів) і чесність фейлу (якщо дані створилися через API, а тест профілю впав — виною профіль, а не setup). Готувати стан через UI означає щоразу протестувати ще й реєстрацію, робити кожен тест заручником чужого екрана й порушувати атомарність. Класична помилка новачка — перевіряти сторінку профілю, щоразу проходячи повну реєстрацію через форму.
Коли підготовка стану через UI все-таки виправдана?
Рівно в одному випадку — коли предметом тесту є сам UI-флоу підготовки. Якщо ми перевіряємо саме реєстрацію через форму, підтвердження email і перший вхід, то ці кроки не setup, а те, що ми, власне, тестуємо, і скорочувати їх до API-виклику не можна — інакше зникне обʼєкт перевірки. У всіх решта випадках UI-шлях у Arrange — це технічний борг, який рано чи пізно стане флаком через чужу верстку чи анімацію. Тобто правило звучить так: тестуєш реєстрацію — проходь її через UI; тестуєш щось після реєстрації — заводь користувача через API.
У чому різниця між сідінгом через API і прямим записом у базу?
Обидва канали швидші й надійніші за UI, але поводяться по-різному з бізнес-логікою. Сідінг через API проходить крізь той самий шар правил, валідацій і тригерів, що й реальний продукт, тож дані виходять узгодженими — так, як їх створив би застосунок. Прямий запис у базу швидший, але легко лишає систему в неможливому стані: можна вставити запис в обхід валідацій, забути повʼязані рядки чи не спрацювати тригер, і тест побачить конфігурацію, якої в проді ніколи не буває. Тому за замовчуванням надійніший API; прямий SQL беруть, коли потрібної ручки в API немає або коли швидкість масового сідінгу критична, свідомо контролюючи цілісність.
Чим відрізняються hard і soft перевірки й коли яку брати?
За замовчуванням перевірки жорсткі (hard): перший невдалий expect кидає виняток і зупиняє тест — це правильний дефолт, бо після зламаного інваріанту наступні асерти часто в непередбачуваному стані й лише шумлять. Мʼякі (soft), як expect.soft(...) у Playwright, не зупиняють тест: усі розбіжності збираються, тест доходить до кінця й падає наприкінці зі списком усього, що не так. Бери hard, коли наступні кроки не мають сенсу без попереднього — немає кнопки «Оплатити», нема що й клікати далі. Бери soft, коли перевіряєш кілька незалежних аспектів одного стану й хочеш побачити всі розбіжності за один прогін: типово це валідація багатьох полів відрендереної сторінки чи тіла API-відповіді.
Яка головна пастка soft-перевірок?
Після expect.soft тест не зупиняється, а продовжується — і в цьому небезпека. Якщо далі йде дія, що залежить від щойно невдалої перевірки, отримаєш каскадний фейл: тест упаде десь нижче, на кроці, який лише постраждав від зламаного стану, а репорт вкаже не туди, де справжня причина. Тому soft доречний наприкінці фази Assert, коли всі перевірки незалежні й після них уже нічого не робиться, а не посеред взаємозалежних кроків. Простий орієнтир: якщо результат наступного рядка залежить від успіху попереднього — там має бути hard, який чесно зупинить тест на першій же розбіжності.
Як правильно називати тест?
Назва має описувати інваріант — властивість системи, яку тест захищає, — а не переказувати кроки. Це перше (а часто й єдине), що бачиш у червоному звіті, тож вона мусить відповідати на питання «яку гарантію продукту ми втратили?». test('клік по кнопці') описує дію й нічого не гарантує; гість не може видалити чужий коментар читається як речення специфікації. Механіка проста: інваріант виноситься в назву, а кроки лишаються в тілі, у фазі Act. «Клік» — деталь реалізації: завтра кнопку замінять на свайп, а інваріант «гість не видаляє чуже» лишиться. Добрий маркер якості — назву можна прочитати як вимогу без слова «тест».
Навіщо тесту прибирати за собою, якщо він і так пройшов?
Бо прибирання (teardown) — це частина незалежності, а не формальність. Тест, який створив запис і не видалив його, лишає слід: наступний прогін тієї ж сьюти ловить дубль або конфлікт унікальності, локально все зелено, а в CI зʼявляється плаваючий фейл. Витік стану особливо болючий у паралельних прогонах, де кілька тестів працюють з одним бекендом одночасно. Тому кожен тест відповідає за повний цикл: сам створив дані — сам їх прибрав, зазвичай у teardown-хуку чи фікстурі. Це те саме правило «прибери після себе», що й у незалежності, просто з боку завершення тесту.
Як AAA, атомарність і назва разом перетворюють сьюту на документацію?
Коли кожен тест має чітке AAA, перевіряє один інваріант і названий як речення специфікації, сьюта починає читатися як перелік гарантій продукту: «гість бачить заглушку порожнього кошика», «оплата переводить замовлення в paid», «невалідний email відхиляється». Новий інженер відкриває набір тестів і розуміє поведінку системи, не читаючи продакшн-код. Головне — ця документація не бреше, бо CI не дасть їй розійтися з реальністю: як тільки поведінка зміниться, тест почервоніє. Підсилює ефект декларативний стиль, коли тест говорить що перевіряє, а не як (деталі локаторів заховані в page object чи кроки). Тест, у якому впереміш CSS-селектори, sleep і бізнес-логіка, документацією не стане — його самого треба документувати.
Три кейси, у яких структура тесту вирішує, чи буде фейл діагностованим: рефакторинг «мега-тесту» в набір атомарних і незалежних, заміна UI-підготовки на сідінг через API, і вибір між hard та soft із демонстрацією каскадної пастки. Скрізь — що не так у «до» і чому «після» надійніше.
Кейс 1. З «мега-тесту» в набір атомарних
Такий тест виглядає як ретельне покриття, а насправді має десяток причин впасти й тримається на власному порядку кроків. Фази перемішані, стан тече від однієї дії до наступної, назва нічого не гарантує.
// ДО (погано): одна дія готує ґрунт для наступної, причин впасти безліч
test('перевірка кошика', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('shared@example.com');
await page.getByLabel('Пароль').fill('qwerty');
await page.getByRole('button', { name: 'Увійти' }).click();
await page.goto('/catalog');
await page.getByRole('button', { name: 'Додати в кошик' }).first().click();
await expect(page.getByTestId('cart-badge')).toHaveText('1');
await page.getByTestId('cart-badge').click();
await page.getByRole('button', { name: 'Оформити' }).click();
await expect(page.getByText('Дякуємо за замовлення')).toBeVisible();
});
Що тут зламано: спільний користувач shared@example.com (два паралельні прогони зіштовхнуться на його кошику), логін і чек-аут як setup для перевірки бейджа, і назва перевірка кошика, яка не скаже, що саме почервоніло. Якщо впаде рядок з бейджем, ти не знатимеш, винен логін, каталог чи сам кошик.
Розбираємо на атоми: логін і додавання в кошик стають окремими інваріантами, кожен зі своїм користувачем і чистим AAA.
// ПІСЛЯ (добре): два незалежні тести, у кожного одна причина впасти
test('додавання товару збільшує лічильник кошика до 1', async ({ page }) => {
// Arrange: свіжий контекст Playwright уже дає чистий профіль
await page.goto('/catalog');
// Act
await page.getByRole('button', { name: 'Додати в кошик' }).first().click();
// Assert — один інваріант
await expect(page.getByTestId('cart-badge')).toHaveText('1');
});
test('оформлення непорожнього кошика показує підтвердження', async ({ page }) => {
// Arrange
await page.goto('/catalog');
await page.getByRole('button', { name: 'Додати в кошик' }).first().click();
// Act
await page.getByTestId('cart-badge').click();
await page.getByRole('button', { name: 'Оформити' }).click();
// Assert
await expect(page.getByText('Дякуємо за замовлення')).toBeVisible();
});
На що звернути увагу:
- Одне цільове Act на тест. У першому — клік «Додати», у другому — оформлення. Причина фейлу тепер очевидна з назви ще до відкриття коду.
- Назва як інваріант.
перевірка кошикаперетворилася на два речення специфікації, які захищають конкретні гарантії й читаються у звіті без коду. - Незалежність від сусіда. Другий тест сам додає товар, а не сподівається на кошик, який лишив перший. Перемішай ранер тести — вердикт не зміниться.
- Ізоляція майже даром. Свіжий
browser contextна кожен тест знімає куки йlocalStorage, тож спільний логін більше не потрібен для розділення станів на клієнті.
Кейс 2. UI-підготовка проти сідінгу через API
Тест про сторінку профілю, який щоразу проходить повну реєстрацію через форму, насправді двадцятий раз тестує реєстрацію. Кожен крок setup може моргнути й завалити тест, який мав би падати лише через профіль.
// ДО (погано): стан готуємо тим самим UI, що й тестуємо
test('користувач бачить своє імʼя в профілі', async ({ page }) => {
await page.goto('/signup');
await page.getByLabel('Імʼя').fill('Ihor');
await page.getByLabel('Email').fill('ihor@example.com'); // фіксований → 409 на повторі
await page.getByLabel('Пароль').fill('qwerty');
await page.getByRole('button', { name: 'Зареєструватися' }).click();
await page.getByRole('link', { name: 'Профіль' }).click();
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Ihor');
});
Проблем одразу дві: setup залежить від верстки й локаторів форми реєстрації (чужий екран), а фіксований email на повторному прогоні дасть конфлікт 409, хоча профіль ні до чого. Готуємо стан прямим викликом API — дешево, стабільно, чесно.
// ПІСЛЯ (добре): стан через API, унікальні дані на кожен прогін
test('користувач бачить своє імʼя в профілі', async ({ page, request }) => {
// Arrange: один POST замість десятка кліків; email унікальний
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');
});
На що звернути увагу:
- Швидкість і стабільність. Один запит замість форми з очікуваннями; підготовка більше не залежить від анімацій і локаторів екрана реєстрації.
- Чесність фейлу. Якщо POST успішний, а асерт на заголовок червоний — виною сторінка профілю, а не setup. UI-підготовка змішала б ці причини й порушила атомарність.
- Унікальні дані на прогін.
Date.now()у пошті прибирає конфлікт409між прогонами; для повного циклу тест ще має прибрати створеного юзера в teardown. - Коли навпаки. Якщо предмет тесту — сама реєстрація через форму, то кроки з
/signupне setup, а обʼєкт перевірки, і скорочувати їх до API не можна.
Кейс 3. Hard проти soft і каскадна пастка
Soft-перевірки зручні, коли треба зібрати всі розбіжності одного стану за один прогін. Але поставлений не на місце soft дає фейл там, де насправді причини немає.
// Доречний 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('В наявності');
});
Тут soft виправданий: якщо зіпсовано ціну й наявність одночасно, звіт покаже обидві розбіжності за один прогін, а не змусить лагодити їх по черзі. А ось антиприклад — soft посеред взаємозалежних кроків:
// Каскад: soft не зупинив тест, і наступна дія падає не там, де причина
test('оплата переводить замовлення в статус paid', async ({ page }) => {
await page.goto('/checkout');
// якщо кнопки немає, soft лише зафіксує це й піде далі...
await expect.soft(page.getByRole('button', { name: 'Оплатити' })).toBeVisible();
// ...а тут тест упаде з «element not found» — репорт вказує сюди, а не на причину
await page.getByRole('button', { name: 'Оплатити' }).click();
await expect(page.getByTestId('order-status')).toHaveText('Оплачено');
});
Виправлення — hard там, де від перевірки залежить наступний крок: await expect(page.getByRole('button', { name: 'Оплатити' })).toBeVisible() без .soft чесно зупинить тест на справжній причині.
Критерій вибору коротко:
| Ситуація | Перевірка | Чому |
|---|---|---|
| Наступний крок залежить від цієї перевірки | hard | Продовжувати немає сенсу — далі каскад і хибне місце фейлу |
| Кілька незалежних полів одного стану, після них дій немає | soft | Один прогін показує всі розбіжності замість лагодження по черзі |
| Валідація тіла API-відповіді чи форми з багатьма полями | soft | Зручно бачити повний список невідповідностей контракту одразу |
Головне правило: soft хороший наприкінці фази Assert, коли перевірки незалежні, а не між кроками, де результат наступного залежить від попереднього.
AAA і структура тіла
- Можу пояснити три фази Arrange–Act–Assert і навіщо їх не перемішувати: читабельність наміру + локалізація фейлу.
- Розумію правило «одне цільове Act»: одна поведінка на тест, а не сценарій із кількох дій.
- Знаю звʼязок AAA з чотирифазним тестом Мезароса (setup–exercise–verify–teardown) і що спільний setup/teardown виносять у фікстури.
Атомарність: одна причина впасти
- Знаю, що атомарність — це «одна причина впасти», а не «один assert на тест».
- Можу пояснити, чому кілька асертів про один інваріант — норма, а кілька не повʼязаних причин в одному тесті — антипатерн.
- Можу показати, як розбити «мега-тест» (логін + створення + профіль + чек-аут) на кілька атомарних.
Незалежність і паралелізм
- Розумію означення незалежності: вердикт не залежить ні від інших тестів, ні від порядку запуску.
- Можу назвати джерела зчеплення: спільні змінні файлу, неявний порядок, спільний стан на бекенді.
- Знаю, що ізоляція
browser contextу Playwright прибирає клієнтський стан, але не рятує від спільного стану на сервері. - Розумію звʼязок незалежності з паралелізацією й флаком: у паралелі порядок недетермінований, тож зчеплені тести падають випадково.
- Знаю, що прибирання за собою (teardown) — частина незалежності, а не формальність, і чому витік стану дає дубль/конфлікт.
Підготовка стану
- Можу обґрунтувати правило «готуй стан через API/БД, а не через UI» трьома аргументами: швидкість, стабільність, чесність фейлу.
- Знаю єдиний виняток: коли сам UI-флоу підготовки і є предметом тесту.
- Розумію різницю сідінгу через API (поважає бізнес-правила й тригери) і прямого запису в БД (швидший, але легко лишає неможливий стан).
Hard vs soft перевірки
- Знаю, що hard — дефолт: перший невдалий
expectзупиняє тест, бо решта в зламаному стані лише шумить. - Можу пояснити, що
expect.softзбирає всі розбіжності й падає наприкінці, і коли це доречно (незалежні аспекти одного стану). - Розумію пастку soft: тест продовжується, тож дія після невдалої перевірки дає каскадний фейл із хибним місцем падіння.
Назва й документація
- Можу відрізнити назву-дію (
клік по кнопці) від назви-інваріанта (гість не видаляє чужий коментар). - Знаю маркер якісної назви: читається як речення специфікації без слова «тест».
- Розумію, як AAA + атомарність + назва-інваріант перетворюють сьюту на живу документацію, яку CI не дає розсинхронити з кодом.
На які три фази AAA розбиває тіло тесту?
Питання
Що таке Arrange–Act–Assert (AAA)?