Тестовий стек зсередини: ранер, фікстури, хуки, конфігурація
Зміст
Junior приходить в автоматизацію і бачить магію: пишеш test('...'), запускаєш одну команду — і звідкись беруться браузер, паралельні воркери, скриншот при падінні й HTML-звіт. Поки все зелене, магія не заважає. Але щойно тест починає падати «через раз», або beforeEach виконується двічі, або дані з одного тесту раптом впливають на інший — з'ясовується, що ти не розумієш, хто саме і в якому порядку все це запускає. І години йдуть на дебаг інфраструктури замість продукту.
Ця глава — карта тестового стека зсередини: що таке ранер (test runner), звідки беруться перевірки, як влаштовані хуки й фікстури, де живе конфігурація і як твій код перетворюється на звіт. На співбесіді тема має середню вагу, але дуже показова: питання «чим beforeAll відрізняється від beforeEach» чи «що таке фікстура» миттєво відділяє того, хто розуміє свій інструмент, від того, хто копіює приклади не думаючи.
Тестовий стек — це не один інструмент, а кілька шарів
Слово «Playwright» чи «CodeceptJS» звучить як єдина сутність, але за ним ховається кілька окремих ролей. Розуміти їх окремо важливо, бо на різних проєктах вони зібрані по-різному: десь усе в одному пакеті, десь — з окремих бібліотек, склеєних вручну.
| Шар | Роль | Приклади |
|---|---|---|
| Ранер (test runner) | Знаходить тести, оркеструє їх, ізолює, збирає вердикти | Playwright Test, Jest, Vitest, Mocha |
| Бібліотека тверджень (assertion library) | Формулює перевірку і кидає помилку при розбіжності | Playwright expect, Jest expect, Chai |
| Драйвер / адаптер | Керує браузером чи транспортом | Playwright, WebDriver, CodeceptJS-хелпери |
| Фікстури й хуки | Готують і прибирають оточення тесту | вбудовані в ранер |
| Конфігурація | Виносить параметри (оточення, таймаути) з коду | playwright.config.ts, codecept.conf.ts |
| Репортер (reporter) | Перетворює вердикти на звіт для людини й CI | HTML, list, JUnit XML, Allure |
Playwright і Cypress постачають усі шари одним пакетом. Класична зв'язка Mocha + Chai + WebdriverIO, навпаки, збирається з окремих деталей: Mocha — ранер, Chai — твердження, WebdriverIO — драйвер. CodeceptJS додає над цим сценарний шар (I.click, I.see), а всередині історично спирається на Mocha. Коли на співбесіді питають «чим Playwright відрізняється від Selenium», половина відповіді — саме про те, які шари вони беруть на себе, а які лишають тобі.
Ранер: оркестратор, який виносить вердикт
Ранер — це диригент. Сам він нічого не «тестує»: він знаходить твої файли (зазвичай за маскою на кшталт *.spec.ts), будує з describe і test дерево тестів, розкладає їх по воркерах, запускає в правильному порядку хуки й фікстури, ізолює кожен тест і збирає результати, щоб віддати репортеру.
describe (або test.describe) групує тести; test (в інших ранерах — it) описує один кейс. Тіло тесту — звичайна async-функція.
import { test, expect } from '@playwright/test';
test.describe('Логін', () => {
test('успішний вхід', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByRole('button', { name: 'Увійти' }).click();
await expect(page.getByRole('heading')).toHaveText('Кабінет');
});
});
Ключова думка, яку junior часто пропускає: ранер вважає тест проваленим тоді, коли з його функції вилітає виняток (exception). Немає ніякого окремого «статусу fail», який тест сам собі проставляє. Дійшли до кінця функції без винятку — passed. Десь усередині щось кинуло помилку — failed. А хто найчастіше кидає ту помилку? Бібліотека тверджень.
Бібліотека тверджень: перевірка, що вміє падати
expect — це фасад над набором матчерів (matchers): toBe, toEqual, toHaveText, toBeVisible тощо. Механізм простий: матчер порівнює фактичне значення з очікуваним і, якщо вони не збігаються, кидає AssertionError. Ранер ловить її й позначає тест червоним. Ось і весь зв'язок між «перевіркою» в голові тестувальника та «вердиктом» у звіті.
Бібліотеки тверджень відрізняються не лише синтаксисом. Найважливіша для AQA відмінність — web-first assertions у Playwright. Звичайний expect(2).toBe(2) перевіряє значення один раз, тут і зараз. А expect(locator).toBeVisible() не перевіряє одноразово: він повторює перевірку, поки умова не стане істинною або поки не вичерпається таймаут очікування (expect timeout). Це вбудований захист від гонок: елемент, який з'явиться за 200 мілісекунд, не завалить тест.
Звідси одна з найпоширеніших пасток новачка:
// ПОГАНО: значення «вирвано» ДО перевірки, ретраю немає
expect(await locator.isVisible()).toBe(true);
// ДОБРЕ: web-first assertion сама чекає й повторює
await expect(locator).toBeVisible();
У першому рядку await виконує isVisible() один раз, отримує false, і toBe(true) одразу падає — ти власноруч викинув автоочікування. Це типове джерело флаку (детальніше — у главі /avtomatyzatsiia/flaki-testy-prychyny-diahnostyka-likuvannia).
Ще одна практична річ — hard vs soft перевірки. Звичайний expect при першій розбіжності зупиняє тест (hard). expect.soft фіксує провал, але дає тесту дійти до кінця, щоб зібрати всі помилки за один прогін — зручно, коли перевіряєш багато незалежних полів на одній сторінці. Різницю між assertion як перевіркою й архітектурою тесту докладніше розбирає глава /avtomatyzatsiia/anatomiia-avtotestu-aaa-nezalezhnist-atomarnist.
Хуки життєвого циклу і їхні пастки
Хуки (lifecycle hooks) — це місця, куди ти вішаєш підготовку і прибирання. Їх чотири, і різниця між ними — питання «як часто»:
beforeAll— раз перед усіма тестами скоупу (у Playwright — раз на файл у межах воркера, що його виконує);beforeEach— перед кожним тестом;afterEach— після кожного тесту, навіть якщо той упав;afterAll— раз наприкінці.
Те, що afterEach виконується навіть після провального тесту, — не дрібниця, а основа клінапу: саме тому прибирання даних вішають туди, а не в кінець тіла тесту (до кінця тіла впалий тест просто не доходить).
Головні пастки:
Спільний мутабельний стан у beforeAll. Спокуса створити користувача чи відкрити з'єднання один раз і користуватися ним у всіх тестах економить секунди, але вбиває незалежність. Якщо тест №2 змінив цей стан, тест №3 отримає його зіпсованим — і тести починають залежати від порядку виконання. Варто ввімкнути рандомізацію чи паралель — і сьюта розсипається. Стан, що змінюється, має жити на рівні beforeEach або фікстури, а не beforeAll.
Помилка в beforeAll валить усі тести файлу. Якщо підготовка на рівні файлу впала, ранер не має середовища для жодного тесту — усі вони позначаються як провалені або пропущені. Один крихкий крок у beforeAll перетворюється на десяток червоних тестів, серед яких справжню причину видно не одразу.
Розсинхрон setup і teardown. Логіка «підготувати в beforeEach, прибрати в afterEach» здається симетричною, але вона крихка: якщо beforeEach упав на півдорозі (створив половину даних і кинув виняток), afterEach уже не знає, що саме встигло створитися й що прибирати. Setup і teardown лежать у різних місцях і нічого не знають одне про одного. Саме цю проблему елегантно розв'язують фікстури.
Фікстури: setup і teardown в одному місці, на вимогу
Фікстура (fixture) — це іменований шматок оточення тесту, у якому підготовка і прибирання зшиті докупи й видаються тесту на вимогу. Тест просто оголошує, що йому потрібен authedPage, і отримує вже залогінену сторінку; звідки вона взялась і хто її потім прибере — не його турбота.
import { test as base, type Page } from '@playwright/test';
type Fixtures = { authedPage: Page };
export const test = base.extend<Fixtures>({
authedPage: async ({ page }, use) => {
await page.goto('/login'); // setup
await login(page);
await use(page); // віддати підготовлену сторінку тесту
await page.context().clearCookies(); // teardown: код ПІСЛЯ use
},
});
Усе, що до use, — підготовка; усе, що після, — прибирання, яке ранер викличе автоматично. Чим це краще за beforeEach:
- Setup і teardown поруч. Немає розсинхрону: прибирання бачить те саме, що створила підготовка.
- На вимогу (lazy). Фікстура запускається, тільки якщо тест її попросив у параметрах.
beforeEachвиконується перед кожним тестом безвідмовно, навіть коли конкретному тесту його результат не потрібен. - Композиція. Фікстури можуть залежати одна від одної (
authedPageспирається наpage), і ранер сам вибудовує правильний порядок — це впорскування залежностей (dependency injection) за іменем параметра.
Окрема важлива вісь — скоуп (scope). Фікстура за замовчуванням test-скоупу: створюється і прибирається на кожен тест — це максимальна ізоляція. Але дорогі ресурси (піднятий сервер, з'єднання з БД) розумно робити worker-скоупними: тоді вони створюються раз на воркер і перевикористовуються всіма тестами в ньому.
export const test = base.extend<{}, { server: Server }>({
server: [async ({}, use) => {
const server = await startServer();
await use(server);
await server.close();
}, { scope: 'worker' }],
});
Тут ховається компроміс: worker-скоуп економить час, але повертає ризик спільного стану — усе, що написано в попередньому розділі про beforeAll, стосується і worker-фікстур. Зв'язок фікстур зі скоупом воркера докладніше розбирає глава /avtomatyzatsiia/paralelizatsiia-vorkery-shardinh-masshtabuvannia, а стратегію самих даних — /avtomatyzatsiia/test-dani-fabryky-fikstury-izoliatsiia (це канонічна глава про фабрики й ізоляцію). У CodeceptJS роль фікстур частково грають хелпери й Before/After, а спільні дані прокидають через контейнер — модель інша, але ідея «підготуй оточення й ізолюй тест» та сама.
Конфігурація: оточення, base URL, таймаути
Конфіг — це файл, куди виносять усе, що не є логікою конкретного тесту: на якому середовищі бігти, звідки брати адресу, скільки чекати. Прибите цвяхами в коді значення (page.goto('https://staging.example.com/login')) означає, що той самий тест не запустиш ні локально, ні на проді без правок. Тому адресу задають один раз через baseURL, а в тестах пишуть відносні шляхи.
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
},
timeout: 30_000, // на весь тест
expect: { timeout: 5_000 }, // на одну web-first перевірку
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
],
});
Тепер await page.goto('/login') у тесті сам підставить baseURL, а середовище перемикається однією змінною оточення (BASE_URL), яку в CI підкладають зі сховища секретів (механіка секретів — тема розділу «Git і CI/CD»). Дефолт через ?? рятує локальний запуск.
Найбільша плутанина новачка — таймаути бувають різних рівнів, і це різні речі:
| Таймаут | Що обмежує |
|---|---|
| test timeout | Скільки живе весь тест цілком |
| expect timeout | Скільки одна web-first перевірка повторює спробу |
| action timeout | Скільки чекає одна дія (click, fill) |
| navigation timeout | Скільки чекає перехід на сторінку |
Плутати їх — значить ставити хибний діагноз. «Тест падає за 5 секунд, хоча я дав йому 30» — класика: 30 секунд це timeout на весь тест, а конкретна перевірка впала по своєму 5-секундному expect timeout. Поле projects — окремий важіль: воно множить один і той самий набір тестів на матрицю браузерів чи середовищ (той самий сценарій на Chromium і WebKit — два «проєкти» з різним use).
Теги й фільтрація: політика, а не декор
Коли тестів стають сотні, ганяти всі на кожен пуш стає дорого. Тут вмикається фільтрація. Тег (у Playwright — рядок на кшталт @smoke у назві тесту або в опції tag) — це мітка, за якою ранеру кажуть, що саме запускати.
test('швидка перевірка входу', { tag: '@smoke' }, async ({ page }) => {
// ...
});
Запуск лише позначених:
npx playwright test --grep @smoke
Фільтрувати можна не тільки за тегом: за файлом, за фрагментом назви, за проєктом. Практична цінність — розкладка прогонів: швидкий @smoke ганяють на кожен PR, а повну регресію — вночі за розкладом. Це не косметика, а політика довіри до сьюти (докладніше про розкладку — у главі про shift-left цього розділу і в розділі «Git і CI/CD»). Поруч живуть анотації test.skip, test.fixme, test.fail — вони не про фільтрацію, а про свідоме виключення чи очікуваний провал конкретного тесту. У CodeceptJS теги вішають на сценарій (Scenario('...', ...).tag('@smoke') або @smoke у назві) і так само відбирають через --grep.
Шлях від коду до звіту
Складемо все докупи. Що відбувається між npx playwright test і зеленою чи червоною цифрою в CI:
Дві точки на цьому шляху варто закарбувати. По-перше, артефакти народжуються з провалу: скриншот, відео і trace ранер зазвичай зберігає саме тоді, коли тест упав, — це і є докази для тріажу. По-друге, репортер віддає результат у двох виглядах: HTML — для людини, а JUnit XML — для машини. Саме JUnit XML є спільною мовою (lingua franca), яку розуміють майже всі CI-системи: з нього дашборд дізнається, скільки тестів упало, і чи блокувати мерж. «Звіт як продукт» — окрема канонічна тема глави /avtomatyzatsiia/zvitnist-artefakty-triazh-trendy.
Типові помилки
- Виглядає як баг застосунку, а насправді — протік стану. Локатор не знаходить елемент, тест червоний — але причина не в продукті, а в тому, що попередній тест лишив дані через спільну
beforeAll-фікстуру. Перевір ізоляцію раніше, ніж заводити баг. - Виглядає, що застосунок «повільний», а насправді — переплутані рівні таймаутів. Тест «падає за 5 секунд при timeout 30» — це expect timeout однієї перевірки, а не таймаут тесту.
- Виглядає, що
expectчекає, а насправді ретраю немає.expect(await locator.isVisible()).toBe(true)вирвав значення до перевірки — автоочікування вимкнене власноруч. - Виглядає, що
afterEachприбере дані, а насправді setup упав на півдорозі. Прибирання не знає, що встигло створитися. Аргумент за фікстури, де setup і teardown зшиті. - Виглядає, що тег працює, а насправді grep нічого не матчить. Друкарська помилка в тезі або не той синтаксис — і фільтр тихо ганяє все або нічого. Перевіряй, скільки тестів реально відібралось.
- Виглядає, що конфіг застосувався, а насправді env-змінна не підвантажилась. Немає dotenv чи інша назва змінної — тест пішов на дефолтний
localhostі «загадково» червоний саме в CI.
Підсумок
- Ранер оркеструє й виносить вердикт; тест провалений, коли з нього вилітає виняток. Бібліотека тверджень кидає цей виняток при розбіжності.
beforeAll— раз на скоуп,beforeEach— на кожен тест. Спільний мутабельний стан уbeforeAll= залежність від порядку й крихка сьюта.- Фікстура — це setup і teardown в одному місці, на вимогу й композовна; для ізоляції та ресурсів вона надійніша за хуки, а скоуп (
testvsworker) керує ціною ізоляції. - Конфіг виносить оточення,
baseURLі таймаути з коду; таймаути ієрархічні, і плутати їхні рівні — це ставити хибний діагноз. - Теги — це політика фільтрації (smoke на PR, регресія вночі), а шлях до звіту закінчується
JUnit XML, який читає CI.
Що питають на співбесіді
- «Чим
beforeAllвідрізняється відbeforeEach? Коли який брати?» Дивляться, чи розумієш ізоляцію й ризик спільного стану, а не лише «один раз проти щоразу». - «Що таке фікстура і чим вона краща за
beforeEach?» Сильна відповідь: setup+teardown разом, на вимогу, композиція через залежності, керований скоуп. - «Які бувають рівні таймаутів?» Тест, expect, дія, навігація — і чи не сплутаєш, коли який спрацював.
- «Тест зелений локально, червоний у CI — з чого почнеш?» Оточення й конфіг, env-змінні та
baseURL, потім артефакти й trace. Тема-місток до флакі й до CI. - «Hard vs soft assertions? Що таке web-first assertion?» Перевіряють розуміння, що перевірка може повторюватись і що зупиняє тест.
- «Як зробити, щоб smoke ганявся на PR, а повна регресія — вночі?» Теги плюс
--grepплюс розкладка в CI. - «Куди дівається результат тесту — як CI дізнається, що впало?» Репортер і
JUnit XMLяк спільний формат.
Джерела
- Playwright — Test fixtures — фікстури,
test.extend, скоупиtest/worker. - Playwright — Test configuration —
baseURL, рівні таймаутів,projects. - Playwright — Annotations and tags — теги,
--grep,skip/fixme/fail. - Jest — Setup and Teardown — хуки життєвого циклу і порядок їх виконання.
- CodeceptJS — Configuration — конфіг, хелпери, оточення.
- ISTQB — силабус CTFL 4.0 торкається інструментів лише концептуально (розділ «Підтримка тестування інструментами»); практична глибина автоматизації — у силабусі Certified Tester Test Automation Engineering (CT-TAE).
Що таке ранер (test runner) і за що він відповідає?
Ранер — це оркестратор прогону: сам він нічого не перевіряє, а керує всім навколо тесту. Він знаходить файли за маскою (типово *.spec.ts), будує з describe/test дерево кейсів, розкладає їх по воркерах, у правильному порядку запускає хуки й фікстури, ізолює кожен тест і збирає вердикти для репортера. Тіло самого тесту — звичайна async-функція, яку ранер викликає. Junior часто плутає ранер із бібліотекою браузерної автоматизації, хоча це різні шари: ранер диригує, а клацає й вводить текст уже драйвер. Приклади ранерів — Playwright Test, Jest, Vitest, Mocha.
З яких шарів складається тестовий стек і навіщо їх розрізняти?
За словом «Playwright» чи «CodeceptJS» ховається не один інструмент, а кілька окремих ролей: ранер (оркестрація), бібліотека тверджень (формулює перевірку й кидає помилку), драйвер/адаптер (керує браузером чи транспортом), хуки й фікстури (готують і прибирають оточення), конфігурація (виносить параметри) та репортер (робить звіт). Розрізняти їх важливо, бо на різних проєктах стек зібраний по-різному: Playwright і Cypress дають усі шари одним пакетом, а класична зв'язка Mocha + Chai + WebdriverIO склеюється з окремих деталей. Коли на співбесіді питають «чим Playwright відрізняється від Selenium», половина відповіді — саме про те, які шари інструмент бере на себе, а які лишає тобі. Той, хто бачить шари, легко читає чужий стек; той, хто ні, копіює приклади не розуміючи, чому вони працюють.
Як ранер вирішує, що тест провалився?
Ранер вважає тест провальним тоді, коли з його функції вилітає виняток (exception) — і ніяк інакше. Немає окремого «статусу fail», який тест сам собі проставляє: дійшли до кінця функції без винятку — passed, десь усередині щось кинуло помилку — failed. Це проста, але фундаментальна модель, бо вона пояснює, звідки взагалі береться вердикт. Найчастіше той виняток кидає бібліотека тверджень, коли фактичне значення не збіглося з очікуваним. Зрозумівши це, junior перестає дивуватися, чому «тест зелений, хоча перевірка насправді не відпрацювала» — часто перевірку просто забули заавейтити, і виняток не встиг вилетіти.
Що таке бібліотека тверджень і як вона пов'язана з вердиктом?
Бібліотека тверджень — це шар, що формулює перевірку й кидає помилку при розбіжності. expect тут працює як фасад над набором матчерів (matchers): toBe, toEqual, toHaveText, toBeVisible. Механізм елементарний: матчер порівнює фактичне з очікуваним і, якщо вони різні, кидає AssertionError. Ранер ловить цю помилку й фарбує тест червоним. Тобто «перевірка» в голові тестувальника перетворюється на «вердикт» у звіті саме через виняток, який кидає матчер, — це і є весь зв'язок між assertion і статусом тесту.
Чим web-first assertion у Playwright відрізняється від звичайної перевірки?
Звичайне твердження перевіряє значення один раз, тут і зараз: expect(2).toBe(2) порівнює й одразу дає вердикт. Web-first assertion (наприклад expect(locator).toBeVisible()) поводиться інакше — вона повторює перевірку в циклі, поки умова не стане істинною або поки не вичерпається expect timeout. Це вбудований захист від гонок: елемент, який відрендериться за 200 мілісекунд, не завалить тест, бо перевірка дочекається його появи. Практичний наслідок — з web-first assertions рідше потрібні ручні очікування, а флаку від «не встиг з'явитися» стає значно менше. Важливо, що це властивість саме перевірок над локаторами, а не будь-якого expect.
Чому expect(await locator.isVisible()).toBe(true) — антипатерн?
Тому що ти власноруч викидаєш автоочікування й перетворюєш web-first перевірку на одноразову. Спочатку await locator.isVisible() виконується один раз і повертає булеве значення (наприклад false, бо елемент ще не встиг з'явитися), а вже потім toBe(true) порівнює цей застиглий результат — і одразу падає, ретраю немає. Значення «вирвано» з часу до того, як перевірка встигла щось дочекатися. Правильний варіант — await expect(locator).toBeVisible(): тут перевірка сама циклічно чекає на видимість до вичерпання таймауту. Це один із найтиповіших генераторів флаку в junior-коді.
Чим hard-перевірка відрізняється від soft і коли брати soft?
Звичайний expect — hard: при першій же розбіжності він кидає виняток і зупиняє тест, решта перевірок не виконуються. expect.soft — soft: він фіксує провал, але не перериває тест, тож той доходить до кінця й збирає всі порушення за один прогін. Soft зручний, коли на одній сторінці треба незалежно перевірити багато полів і хочеться побачити відразу весь список розбіжностей, а не ганяти прогін по колу, лагодячи по одній. Але soft не робить тест «незалежним» від логіки: якщо після провальної soft-перевірки код фізично не може продовжитись (наприклад, елемента немає), далі все одно впаде. Тому soft — про повноту діагностики, а не про те, щоб ігнорувати провали.
Чим beforeAll відрізняється від beforeEach і коли який брати?
Різниця — у частоті: beforeAll виконується раз перед усіма тестами скоупу (у Playwright — раз на файл у межах воркера), а beforeEach — перед кожним тестом окремо. Симетрично afterAll спрацьовує раз наприкінці, а afterEach — після кожного тесту, навіть після провального. Правило вибору просте: усе, що є спільним і незмінним (наприклад, підняти сервер, прочитати конфіг), можна класти у beforeAll; усе, що змінюється або має бути свіжим для кожного тесту (тестові дані, стан застосунку), — тільки у beforeEach або фікстуру. Сильна відповідь тут — не про «один раз проти щоразу», а про ізоляцію: спільний мутабельний стан у beforeAll робить тести залежними від порядку.
Чому клінап прибирають в afterEach, а не в кінці тіла тесту?
Бо afterEach виконується навіть тоді, коли тест упав, а до кінця тіла впалий тест просто не доходить. Якщо покласти прибирання останнім рядком тесту, то будь-який виняток вище нього залишить дані неприбраними — і наступні тести отримають забруднене середовище. afterEach же ранер викликає гарантовано, незалежно від вердикту, тому саме туди природно вішати видалення створених записів, закриття з'єднань, скидання стану. Це робить клінап надійним, а не «якщо пощастить дійти». Той самий принцип у фікстурах реалізовано ще акуратніше — через код після use.
Чим небезпечний спільний мутабельний стан у beforeAll?
Він убиває незалежність тестів. Спокуса створити користувача чи відкрити з'єднання один раз і користуватися ними скрізь економить секунди, але щойно один тест змінить цей стан — наступний отримає його зіпсованим, і тести починають залежати від порядку виконання. Поки вони йдуть послідовно й у фіксованому порядку, проблема ховається; варто ввімкнути рандомізацію чи паралель — і сьюта розсипається «на рівному місці». Симптом упізнаваний: тест зелений поодинці, але червоний у повному наборі, а результат залежить від порядку запуску. Тому стан, що змінюється, має жити на рівні beforeEach або фікстури, а незмінні дорогі ресурси — окремо й свідомо.
Що станеться, якщо виняток вилетить у beforeAll?
Він завалить усі тести файлу разом. Якщо підготовка на рівні файлу впала, ранер не має середовища для жодного тесту скоупу, тож усі вони позначаються як провалені або пропущені. Один крихкий крок у beforeAll — і замість одного зрозумілого падіння отримуєш десяток червоних тестів, серед яких справжню причину видно не одразу. Це ще один аргумент тримати beforeAll максимально тонким і надійним: чим більше в ньому крихкої логіки, тим ширший радіус ураження при збої. Для тріажу корисно знати цей патерн: масове падіння всього файлу — привід одразу дивитися в його setup, а не в кожен тест окремо.
Що таке фікстура і чим вона краща за beforeEach?
Фікстура (fixture) — це іменований шматок оточення тесту, у якому setup і teardown зшиті докупи й видаються тесту на вимогу. Тест просто оголошує в параметрах, що йому потрібен, скажімо, authedPage, і отримує вже залогінену сторінку; звідки вона взялась і хто її прибере — не його турбота. Перевага перша: setup і teardown лежать поруч (усе до use — підготовка, усе після — прибирання), тож немає розсинхрону, коли teardown не знає, що встиг створити setup. Перевага друга — lazy: фікстура запускається, тільки якщо тест її попросив, тоді як beforeEach відпрацьовує перед кожним тестом безвідмовно. Перевага третя — композиція: фікстури залежать одна від одної, і ранер сам вибудовує порядок, тобто це впорскування залежностей (dependency injection) за іменем параметра.
У чому суть скоупу фікстури test проти worker?
Скоуп визначає, як часто фікстура створюється й прибирається. За замовчуванням фікстура має test-скоуп: вона живе рівно один тест — це максимальна ізоляція, кожен кейс отримує свіже оточення. Але дорогі ресурси (піднятий сервер, з'єднання з БД) розумно робити worker-скоупними: тоді вони створюються раз на воркер і перевикористовуються всіма його тестами. Компроміс очевидний: worker-скоуп економить час, але повертає ризик спільного стану — усе, що ми казали про beforeAll, стосується і worker-фікстур. Тому вибір скоупу — це свідомий баланс «ціна ізоляції проти швидкості», а не технічна дрібниця.
Навіщо виносити baseURL у конфіг замість того, щоб писати повну адресу в тесті?
Бо прибите цвяхами значення на кшталт page.goto('https://staging.example.com/login') прив'язує тест до одного середовища: локально чи на проді той самий тест уже не запустиш без правок коду. Коли адресу задано один раз через baseURL, а в тестах пишуть відносні шляхи (page.goto('/login')), середовище перемикається однією змінною оточення — код тестів лишається незмінним. У CI цю змінну підкладають зі сховища секретів, а локальний дефолт через ?? рятує запуск на машині розробника. Це і про перевикористання, і про безпеку: реальні адреси й секрети не осідають у git. Плюс так конфіг стає єдиним місцем правди про те, куди взагалі бити тестам.
Які бувають рівні таймаутів і чому їх плутати небезпечно?
Таймаути ієрархічні, і це різні речі: test timeout обмежує, скільки живе весь тест цілком; expect timeout — скільки одна web-first перевірка повторює спробу; action timeout — скільки чекає одна дія (click, fill); navigation timeout — скільки чекає перехід на сторінку. Плутати їх — значить ставити хибний діагноз. Класика: «тест падає за 5 секунд, хоча я дав йому 30» — тут 30 секунд це test timeout на весь тест, а впала конкретна перевірка по своєму 5-секундному expect timeout. Побачивши падіння по таймауту, спершу треба зрозуміти, який саме таймаут спрацював, і лише тоді крутити відповідне число. Наосліп піднімати test timeout, коли впав expect, — марно.
Що таке projects у конфізі Playwright?
projects — це важіль, що множить один і той самий набір тестів на матрицю середовищ чи браузерів. Кожен «проєкт» — це запис із власним блоком use: наприклад, один і той самий сценарій ганяється як проєкт chromium і проєкт webkit, і різняться вони лише параметрами оточення. Так покриття по кількох браузерах не потребує дублювання тестів — ранер сам проганяє їх під кожен профіль. Через projects зручно розділяти й середовища чи набори налаштувань (мобільний в'юпорт проти десктопного). Це частина того, як конфіг виносить варіативність із коду тестів у декларативний опис.
Що таке теги й навіщо --grep у прогонах?
Тег — це мітка (у Playwright — рядок на кшталт @smoke у назві тесту чи в опції tag), за якою ранеру кажуть, що саме запускати. Коли тестів сотні, ганяти всі на кожен пуш стає дорого, і тут вмикається фільтрація: npx playwright test --grep @smoke відбере лише позначені. Практична цінність — розкладка прогонів: швидкий @smoke ганяють на кожен PR, а повну регресію — вночі за розкладом. Фільтрувати можна не тільки за тегом, а й за файлом, фрагментом назви чи проєктом. Це не косметика, а політика довіри до сьюти: чим осмисленіша фільтрація, тим швидший зворотний зв'язок розробнику без втрати покриття.
Тест зелений локально, а червоний у CI — з чого почнеш діагностику?
Найперше — оточення й конфіг, бо саме тут найчастіше й криється різниця. Перевіряю env-змінні та baseURL: чи не пішов тест у CI на дефолтний localhost через те, що dotenv не підвантажився або змінна названа інакше — «загадкова» червоність саме в пайплайні майже завжди звідси. Далі — секрети й доступи: чи доїхав токен, чи те середовище взагалі піднялося. Тільки потім лізу в артефакти прогону: скриншот, відео і trace показують, що реально бачив тест у момент падіння. Окремо тримаю в голові патерн паралелізму: якщо поодинці зелено, а в CI під воркерами плаває, підозра зміщується на ізоляцію стану, гонки чи rate limit. Дисципліна проста — не заявляти причину без доказу з артефактів чи логів.
Куди дівається результат тесту — як CI дізнається, що саме впало?
Вердикти від ранера підхоплює репортер і віддає їх у двох виглядах: HTML — для людини (зручно клікати й дивитися артефакти) і JUnit XML — для машини. Саме JUnit XML є спільною мовою, яку розуміють майже всі CI-системи: з нього дашборд дізнається, скільки тестів упало, які й чи блокувати мерж. Тобто ланцюг такий: тест кинув (чи не кинув) виняток, ранер зафіксував вердикт, репортер серіалізував його в XML, а гейт у пайплайні прочитав XML і вирішив долю збірки. Ще одна деталь, яку варто закарбувати: артефакти народжуються з провалу — скриншот, відео і trace ранер зазвичай зберігає саме тоді, коли тест упав, і це головні докази для тріажу. Без розуміння цього ланцюга «CI сказав, що впало» звучить як магія, хоча за ним проста серіалізація вердиктів.
Три кейси, де розуміння стека відрізняє швидкий фікс від багатогодинного дебагу інфраструктури: сьюта, що розсипається під паралеллю через спільний стан у beforeAll; фікстура, яка знімає розсинхрон setup/teardown і вмикається на вимогу; і читання падіння по таймауту, коли важливо не сплутати рівні. Скрізь — що дивитися і чому.
Кейс 1. beforeAll протікає: зелено поодинці, червоно в наборі
Симптом класичний: локально в один потік усе зелене, а в CI під воркерами частина тестів «випадково» червоніє, і результат залежить від порядку запуску. Ось код, який це породжує:
import { test, expect } from '@playwright/test';
// ПОГАНО: один користувач на весь файл, стан спільний і мутабельний
let userBalance = 100;
test.beforeAll(async () => {
userBalance = 100; // «скидаємо» раз на файл
});
test('списання зменшує баланс', async () => {
userBalance -= 30;
expect(userBalance).toBe(70);
});
test('баланс новачка дорівнює 100', async () => {
expect(userBalance).toBe(100); // впаде, якщо перший тест уже відпрацював
});
Поки тести йдуть у написаному порядку й послідовно, другий іноді проскакує. Але варто ранеру запустити їх у різному порядку або на різних воркерах — і userBalance приходить у другий тест уже зіпсованим. Виправлення — перенести мутабельний стан на рівень кожного тесту:
import { test, expect } from '@playwright/test';
test('списання зменшує баланс', async () => {
let balance = 100; // свій стан у кожного тесту
balance -= 30;
expect(balance).toBe(70);
});
test('баланс новачка дорівнює 100', async () => {
const balance = 100;
expect(balance).toBe(100);
});
Що дивитися і чому:
- Залежність від порядку — головний симптом. Якщо перестановка тестів місцями чи вмикання паралелі змінює вердикт, шукай спільний мутабельний стан, а не баг продукту.
beforeAllтут — перший підозрюваний. beforeAllгодиться лише для незмінного. Підняти сервер, прочитати конфіг — так; створити дані, які тести змінюватимуть, — ні. Усе, що мутує, живе уbeforeEachабо фікстурі.- Радіус ураження ширший, ніж здається. Якби «скидання» балансу впало у
beforeAll, червоними стали б усі тести файлу одразу — і справжню причину довелося б шукати серед десятка однакових падінь.
Кейс 2. Фікстура замість пари beforeEach/afterEach
«Підготувати в beforeEach, прибрати в afterEach» виглядає симетрично, але крихке: якщо beforeEach кинув виняток на півдорозі (створив половину даних), afterEach уже не знає, що саме встигло створитися. Setup і teardown лежать у різних місцях і нічого не знають одне про одного. Фікстура зшиває їх в одне місце й видає на вимогу:
import { test as base, type Page } from '@playwright/test';
type Fixtures = { authedPage: Page };
export const test = base.extend<Fixtures>({
authedPage: async ({ page }, use) => {
await page.goto('/login'); // setup
await page.getByLabel('Email').fill('user@example.com');
await page.getByRole('button', { name: 'Увійти' }).click();
await use(page); // віддали залогінену сторінку тесту
await page.context().clearCookies(); // teardown — код ПІСЛЯ use
},
});
Тест просто просить authedPage у параметрах і отримує вже готову сторінку:
import { expect } from '@playwright/test';
import { test } from './fixtures';
test('кабінет доступний залогіненому', async ({ authedPage }) => {
await authedPage.goto('/dashboard');
await expect(authedPage.getByRole('heading')).toHaveText('Кабінет');
});
Дорогий ресурс варто підняти раз на воркер, а не на кожен тест — це задає скоуп:
export const test = base.extend<{}, { server: Server }>({
server: [async ({}, use) => {
const server = await startServer(); // раз на воркер
await use(server);
await server.close();
}, { scope: 'worker' }],
});
Що дивитися і чому:
- Межа проходить по
use. Усе доuse— підготовка, усе після — прибирання, яке ранер викличе автоматично навіть при падінні тесту. Розсинхрону немає: teardown бачить те саме, що створив setup. - Фікстура lazy,
beforeEach— ні. Фікстура запускається, лише якщо тест назвав її в параметрах;beforeEachвідпрацьовує перед кожним тестом безвідмовно, навіть коли результат конкретному тесту не потрібен. scope: 'worker'економить, але повертає ризик. Один сервер на воркер — швидше, проте це знову спільний стан: усе, що написано в кейсі 1 проbeforeAll, стосується і worker-фікстур. Скоуп — це свідомий вибір «ціна ізоляції проти швидкості».- Композиція безкоштовна.
authedPageспирається на вбудовануpage, ранер сам вибудовує порядок — це впорскування залежностей за іменем параметра.
Кейс 3. Падіння по таймауту: не сплутати рівні
«Тест падає за 5 секунд, хоча я дав йому 30» — щоденна плутанина. Розберемо на конфізі, де таймаути різних рівнів задані окремо:
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
actionTimeout: 10_000, // одна дія: click, fill
navigationTimeout: 15_000, // перехід на сторінку
},
timeout: 30_000, // весь тест цілком
expect: { timeout: 5_000 }, // одна web-first перевірка
});
Тепер тест «падає за 5 секунд» на такому рядку:
// перевірка циклічно чекає видимості й здається через свій 5-секундний expect timeout
await expect(page.getByRole('alert')).toBeVisible();
Повідомлення в звіті прямо називає винуватця:
Error: Timed out 5000ms waiting for expect(locator).toBeVisible()
Що дивитися і чому:
- 30 секунд тут ні до чого.
timeout: 30_000обмежує весь тест, а впала конкретна перевірка по своємуexpect.timeoutу 5 секунд. Піднімати test timeout — марно: перевірка все одно здасться на п'ятій секунді. - Спочатку читай, який таймаут спрацював. У тексті помилки видно рівень:
expect(...).toBeVisible()— це expect timeout;waiting for locator.click()— action timeout;page.goto— navigation timeout. Крутити треба саме те число, яке назвала помилка. - Хибний діагноз коштує годин. Прийняти expect timeout за «повільний застосунок» і піти оптимізувати бекенд — типова помилка; реальна причина може бути в тому, що елемент просто не з'являється, і жоден таймаут його не дочекається.
- Дефолт
baseURLчерез??— окрема страховка. ЯкщоBASE_URLне підвантажився (немає dotenv чи інша назва змінної), тест піде наlocalhostі «загадково» червонітиме саме в CI — це вже інший діагноз, але з тієї ж родини «конфіг не такий, як здається».
Шари стека і ранер
- Можу назвати шари тестового стека (ранер, бібліотека тверджень, драйвер, хуки/фікстури, конфіг, репортер) і пояснити, за що кожен відповідає.
- Розумію, що ранер сам нічого не перевіряє — він знаходить тести, розкладає по воркерах, запускає хуки/фікстури й збирає вердикти.
- Знаю, що Playwright/Cypress дають усі шари одним пакетом, а Mocha + Chai + WebdriverIO склеюється з окремих деталей.
Вердикт і бібліотека тверджень
- Знаю, що тест провалений тоді й тільки тоді, коли з його функції вилітає виняток — окремого «статусу fail» немає, а кидає його зазвичай матчер через
AssertionError. - Розумію різницю між звичайною перевіркою (один раз) і web-first assertion (повторює спробу до expect timeout).
- Можу пояснити, чому
expect(await locator.isVisible()).toBe(true)вимикає автоочікування, аawait expect(locator).toBeVisible()— ні. - Знаю різницю hard vs soft:
expectзупиняє тест при першій розбіжності,expect.softдає зібрати всі порушення за прогін.
Хуки життєвого циклу
- Знаю різницю
beforeAll(раз на скоуп) vsbeforeEach(перед кожним тестом) і коли який брати. - Можу пояснити, чому клінап вішають в
afterEach, а не в кінець тіла тесту (впалий тест до кінця не доходить). - Розумію, чому спільний мутабельний стан у
beforeAll= залежність від порядку й розсипання сьюти під паралеллю. - Знаю, що виняток у
beforeAllвалить усі тести файлу, і вмію впізнати цей патерн у тріажі.
Фікстури і скоуп
- Можу пояснити, що фікстура — це setup і teardown в одному місці, на вимогу (lazy) і композовна (DI за іменем параметра).
- Знаю, де в коді фікстури проходить межа: усе до
use— підготовка, усе після — прибирання. - Знаю різницю скоупів
test(свіже оточення на кожен тест) vsworker(раз на воркер) і компроміс «ізоляція проти швидкості».
Конфігурація і таймаути
- Розумію, чому прибиту в коді адресу виносять у
baseURLз відносними шляхами в тестах, і як середовище перемикається однією env-змінною. - Знаю чотири рівні таймаутів (test, expect, action, navigation), не сплутаю, який спрацював, і читаю «падає за 5 с при timeout 30» як expect timeout.
- Розумію, що
projectsмножить один набір тестів на матрицю браузерів/середовищ без дублювання коду.
Теги, звіт і CI
- Можу пояснити теги як політику фільтрації (
@smokeна PR, регресія вночі) і що--grepфільтрує за тегом/назвою/файлом. - Знаю, що артефакти (скриншот, відео, trace) народжуються з провалу й потрібні для тріажу.
- Можу пояснити роль
JUnit XMLяк спільної мови, з якої CI дізнається, що впало й чи блокувати мерж.
Що робить ранер (test runner) під час прогону?
Питання
Що таке ранер (test runner)?