Навіщо параметризувати тести
Багато перевірок у тестуванні мають однакові кроки і відрізняються лише даними. Форма входу перевіряє порожній email, email без @, порожній пароль, неправильний пароль: щоразу ми відкриваємо сторінку, заповнюємо два поля, натискаємо кнопку і читаємо повідомлення про помилку. Змінюються тільки введені значення та очікуваний текст. Такий підхід називають тестуванням, керованим даними (data-driven testing): сценарій пишемо один раз, а набори даних описуємо окремо, як рядки таблиці перевірок. Інструмент, який дає змогу з одного сценарію отримати багато тестів, називають параметризацією (parameterization).
Документація Playwright виділяє два рівні параметризації: на рівні тесту (test level) і на рівні проєкту (project level). До них природно додаються змінні середовища (environment variables): це параметри, які передаються ззовні під час запуску, наприклад адреса стенда чи облікові дані. У цьому конспекті розберемо всі три способи, а також те, як параметризація поєднується з Page Object і фікстурами з попередніх тем.
Проблема
Почнемо з того, як виглядають негативні тести форми входу, якщо писати кожен окремо. Ось три з них.
import { test, expect } from '@playwright/test';
test('login fails with empty email', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('');
await page.getByLabel('Password').fill('Secret123');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('alert')).toHaveText('Email is required');
});
test('login fails with email without @', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user.example.com');
await page.getByLabel('Password').fill('Secret123');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('alert')).toHaveText('Enter a valid email');
});
test('login fails with wrong password', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('wrong');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('alert')).toHaveText('Wrong email or password');
});Running 3 tests using 1 worker ✓ 1 [chromium] › tests/copy-paste.spec.ts:3:5 › login fails with empty email (494ms) ✓ 2 [chromium] › tests/copy-paste.spec.ts:11:5 › login fails with email without @ (323ms) ✓ 3 [chromium] › tests/copy-paste.spec.ts:19:5 › login fails with wrong password (313ms) 3 passed (2.4s)
Тести працюють, але п'ять рядків логіки повторено тричі. Щоб додати четвертий кейс, доведеться скопіювати блок ще раз.
Чому так стається
Копіювання створює кілька проблем одночасно. Коли змінюється сценарій (наприклад, кнопка тепер називається Log in), правити доводиться кожну копію, і одну з них легко пропустити. Дані розкидані по коду, тож неможливо одним поглядом побачити, які саме кейси покрито, а які забули. І найнепомітніше: копії з часом розходяться. Хтось додає очікування в один тест і забуває про інші, після чого «однакові» тести перевіряють різне.
Рішення
Відокремити дані від сценарію. Дані описуємо як масив об'єктів, по одному на кейс, а сценарій записуємо один раз і в циклі реєструємо окремий тест для кожного елемента масиву. Як саме це зробити, розберемо в наступному розділі.
Коли застосовувати
Параметризація доречна, коли кроки сценарію однакові, а відрізняються тільки вхідні дані й очікуваний результат: валідація форм, пошук за різними запитами, перевірка однієї сторінки під різними ролями чи на різних середовищах. Якщо ж у кейсах відрізняються самі кроки, це різні сценарії, і їх краще писати окремими тестами (до цього повернемося в розділі про типові помилки).
Параметризація на рівні тесту
Цикл навколо test()
У Playwright немає окремої функції на кшталт test.each. Параметризація на рівні тесту зводиться до звичайного циклу TypeScript, усередині якого викликається test(). Кожен виклик test() реєструє новий тест, тож цикл з чотирма ітераціями дає чотири незалежні тести.
import { test, expect } from '@playwright/test';
// Один набір даних = один рядок таблиці перевірок
type InvalidLoginCase = {
title: string;
email: string;
password: string;
error: string;
};
const invalidLogins: InvalidLoginCase[] = [
{ title: 'empty email', email: '', password: 'Secret123', error: 'Email is required' },
{ title: 'email without @', email: 'user.example.com', password: 'Secret123', error: 'Enter a valid email' },
{ title: 'empty password', email: 'user@example.com', password: '', error: 'Password is required' },
{ title: 'wrong password', email: 'user@example.com', password: 'wrong', error: 'Wrong email or password' },
];
for (const { title, email, password, error } of invalidLogins) {
// Назва містить параметр, тому кожен тест має унікальний title
test(`login fails: ${title}`, async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('alert')).toHaveText(error);
});
}Running 4 tests using 1 worker ✓ 1 [chromium] › tests/login-negative.spec.ts:20:7 › login fails: empty email (502ms) ✓ 2 [chromium] › tests/login-negative.spec.ts:20:7 › login fails: email without @ (310ms) ✓ 3 [chromium] › tests/login-negative.spec.ts:20:7 › login fails: empty password (291ms) ✓ 4 [chromium] › tests/login-negative.spec.ts:20:7 › login fails: wrong password (256ms) 4 passed (2.8s)
Тут і далі тести запускаються з прапорцем --workers=1, щоб рядки звіту йшли в тому ж порядку, що й дані. Без нього Playwright роздає тести кільком воркерам, і порядок у звіті може змінюватися; детально паралельний запуск розбирається в темі 10.
Зверніть увагу на кілька деталей у виводі. Усі чотири тести мають однакове місце в коді 20:7: це один і той самий виклик test(), виконаний чотири рази. Відрізняє їх тільки назва, тому назва мусить містити параметр. Кожен тест отримує власну фікстуру page у власному контексті браузера, тож падіння одного кейсу не впливає на інші. Тип InvalidLoginCase описує структуру рядка даних: якщо в якомусь об'єкті пропустити поле error або написати eror, TypeScript покаже помилку ще до запуску. Деструктуризація { title, email, password, error } прямо в заголовку циклу робить тіло тесту коротшим, ніж звернення через testCase.email.
for...of чи forEach
Документація Playwright показує той самий прийом через метод масиву forEach. Обидва варіанти рівноцінні: і цикл for...of, і forEach синхронно проходять масив і викликають test() для кожного елемента.
import { test, expect } from '@playwright/test';
[
{ query: 'mug', heading: 'Found 2 products' },
{ query: 'coffee', heading: 'Nothing found' },
].forEach(({ query, heading }) => {
test(`search "${query}"`, async ({ page }) => {
await page.goto(`/search?q=${query}`);
await expect(page.getByRole('heading', { level: 1 })).toHaveText(heading);
});
});Running 2 tests using 1 worker ✓ 1 [chromium] › tests/foreach.spec.ts:7:7 › search "mug" (293ms) ✓ 2 [chromium] › tests/foreach.spec.ts:7:7 › search "coffee" (252ms) 2 passed (1.6s)
У конспекті далі використовується for...of, бо в ньому дані стоять окремо від циклу і його легше читати, коли масив імпортується з іншого файлу. Головне правило для обох варіантів: функція всередині має бути синхронною. Якщо написати forEach(async (item) => { ... }) і всередині чекати на щось через await перед test(), тест буде зареєстровано вже після того, як Playwright закінчив збирати файл, і раннер його не побачить.
Одна пастка стосується саме циклу: в JavaScript є ще for...in, який легко сплутати з for...of.
Що виведе програма?
Скільки тестів буде зареєстровано і як вони називатимуться? Файл перевірено командою npx playwright test --list, яка лише виводить список тестів, не запускаючи їх.
import { test } from '@playwright/test';
const users = ['Alice', 'Bob'];
for (const name in users) {
test(`greets ${name}`, async () => {});
}Listing tests: [chromium] › for-in.spec.ts:6:7 › greets 0 [chromium] › for-in.spec.ts:6:7 › greets 1 Total: 2 tests in 1 file
Тестів два, але в назвах стоять не імена, а 0 і 1. Цикл for...in перебирає ключі об'єкта, а в масиву ключі — це індекси, записані як рядки. Тому змінна name по черзі отримує '0' і '1', а не 'Alice' і 'Bob'. Помилка підступна: тести реєструються, назви унікальні, і якщо тест не використовує параметр у перевірці, він навіть пройде. Для перебору значень масиву потрібен for...of.
Як Playwright збирає параметризовані тести
Щоб розуміти обмеження параметризації, треба знати, коли саме виконується цикл. Запуск у Playwright проходить у дві фази. Спочатку йде збирання (collection): раннер завантажує тестовий файл і виконує весь код верхнього рівня. Виклики test() у цей момент не запускають тести, а лише реєструють їх: запам'ятовують назву і функцію. Потім настає виконання (execution): воркер отримує список тестів і запускає функції зареєстрованих тестів. Перевіримо це, додавши console.log у різні місця файлу.
import { test } from '@playwright/test';
const sizes = ['S', 'M'];
console.log('file loaded, sizes:', sizes.join(', '));
for (const size of sizes) {
console.log(`declaring test for ${size}`);
test(`adds size ${size}`, async () => {
console.log(`running test for ${size}`);
});
}file loaded, sizes: S, M declaring test for S declaring test for M Running 2 tests using 1 worker file loaded, sizes: S, M declaring test for S declaring test for M running test for S ✓ 1 [chromium] › tests/collect.spec.ts:8:7 › adds size S (4ms) running test for M ✓ 2 [chromium] › tests/collect.spec.ts:8:7 › adds size M (1ms) 2 passed (1.1s)
Вивід показує важливу річ: код верхнього рівня виконався двічі. Перший раз файл завантажив головний процес раннера, щоб дізнатися, які тести існують і скільки їх (рядок Running 2 tests з'являється після цього). Другий раз той самий файл завантажив воркер, бо воркер — окремий процес Node.js, і функції тестів він має отримати з власного завантаження файлу. Тільки після цього воркер запустив тести, і з'явилися рядки running test for .... Якщо воркерів кілька, кожен із них завантажує файл ще раз.
flowchart LR A["Головний процес<br/>завантажує файл"] --> B["Цикл виконується,<br/>test() реєструє назви"] B --> C["Список тестів<br/>передається воркеру"] C --> D["Воркер знову<br/>завантажує файл"] D --> E["Воркер шукає тест<br/>за тією ж назвою"] E --> F["Запускає функцію тесту<br/>з фікстурами"]
З цього механізму випливають два практичні правила. Перше: дані для циклу мають бути доступні синхронно, у момент завантаження файлу. Масив у коді, імпорт модуля чи JSON, синхронне читання файлу (fs.readFileSync) підходять. Отримати дані з мережі перед циклом так не вийде. Друге: дані мають бути детермінованими, тобто однаковими при кожному завантаженні файлу. Воркер шукає тест за назвою, яку зареєстрував головний процес, і якщо назва змінилася між завантаженнями, тест не знайдеться. Наступний розділ показує, як саме це ламається.
Унікальні та стабільні назви
Назва тесту в Playwright — це його ідентифікатор у межах файлу. Разом із назвами груп describe вона утворює шлях назви (title path), за яким раннер відрізняє тести, фільтрує їх і знаходить у воркері. Тому до назв параметризованих тестів є дві вимоги: вони мають бути унікальними і стабільними.
Антипатерн: Дубль у даних дає дубль назви
import { test, expect } from '@playwright/test';
const queries = ['mug', 'tea', 'mug'];
for (const query of queries) {
test(`search by ${query}`, async ({ page }) => {
await page.goto(`/search?q=${query}`);
await expect(page.getByRole('heading')).toBeVisible();
});
}Error: duplicate test title "search by mug", first declared in duplicate.spec.ts:6
at duplicate.spec.ts:6
4 |
5 | for (const query of queries) {
> 6 | test(`search by ${query}`, async ({ page }) => {
| ^
7 | await page.goto(`/search?q=${query}`);
8 | await expect(page.getByRole('heading')).toBeVisible();
9 | });Значення 'mug' трапилося в даних двічі, тому двічі зареєструвався тест з однаковою назвою. Playwright перевіряє унікальність ще на етапі збирання і зупиняє запуск усього файлу: не пройде жоден тест, навіть ті, що не мають дубля. Ту саму помилку дасть назва, яка взагалі не містить параметра, наприклад test('search works', ...) у циклі.
Як правильно
Прибрати повтор з даних, а якщо два кейси справді потрібні (з однаковим запитом, але різними іншими параметрами), включити в назву ту частину даних, якою вони відрізняються. Зручно мати в кожному рядку даних окреме поле title, як у InvalidLoginCase: воно і робить назву унікальною, і пояснює у звіті, що саме перевіряє кейс.
Антипатерн: Випадкові значення в назві
Хочеться згенерувати унікальні email-и прямо в даних і показати їх у назві тесту.
import { test, expect } from '@playwright/test';
const emails = ['first', 'second'].map(
(prefix) => `${prefix}-${Date.now()}@example.com`,
);
for (const email of emails) {
test(`registers ${email}`, async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(email);
});
}
Running 2 tests using 1 worker
✘ 1 [chromium] › tests/random.spec.ts:8:7 › registers first-1790892552453@example.com (0ms)
✘ 2 [chromium] › tests/random.spec.ts:8:7 › registers second-1790892552453@example.com (0ms)
1) [chromium] › tests/random.spec.ts:8:7 › registers first-1790892552453@example.com ─────────────
Test not found in the worker process. Make sure test title does not change.
2) [chromium] › tests/random.spec.ts:8:7 › registers second-1790892552453@example.com ────────────
Test not found in the worker process. Make sure test title does not change.
2 failed
[chromium] › tests/random.spec.ts:8:7 › registers first-1790892552453@example.com ──────────────
[chromium] › tests/random.spec.ts:8:7 › registers second-1790892552453@example.com ─────────────Обидва тести впали за 0 мс, жоден рядок їхнього коду не виконався. Причина в механізмі збирання з попереднього розділу. Головний процес завантажив файл, Date.now() повернув одне число, і тести зареєструвалися з цим числом у назві. Воркер завантажив файл трохи пізніше, Date.now() повернув інше число, і воркер зареєстрував тести з іншими назвами. Коли раннер попросив воркер запустити registers first-1790892552453@example.com, такого тесту у воркера не виявилося. Те саме станеться з Math.random(), генераторами фейкових даних і будь-якими значеннями, що залежать від часу.
Як правильно
Назва і дані для циклу мають бути стабільними, а унікальні значення генеруються вже всередині тесту, тобто у фазі виконання.
import { test, expect } from '@playwright/test';
const prefixes = ['first', 'second'];
for (const prefix of prefixes) {
// Назва стабільна: вона однакова в кожному процесі, який завантажує файл
test(`registers the ${prefix} user`, async ({ page }) => {
// Унікальне значення створюємо вже під час виконання тесту
const email = `${prefix}-${Date.now()}@example.com`;
await page.goto('/login');
await page.getByLabel('Email').fill(email);
await expect(page.getByLabel('Email')).toHaveValue(email);
});
}Running 2 tests using 1 worker ✓ 1 [chromium] › tests/random-fixed.spec.ts:7:7 › registers the first user (407ms) ✓ 2 [chromium] › tests/random-fixed.spec.ts:7:7 › registers the second user (277ms) 2 passed (2.0s)
Добра назва параметризованого тесту читається як речення і містить те, що відрізняє кейс: login fails: empty password, search "coffee" shows "Nothing found". Номер ітерації (case 3) формально унікальний, але у звіті нічого не пояснює, а після вставки нового рядка в середину масиву всі номери зсуваються, і історія результатів у звітах перестає збігатися з кейсами.
Хуки і групування параметризованих тестів
Хуки поза циклом
Хуки beforeEach і afterEach з теми 2 працюють і для параметризованих тестів. Документація радить у більшості випадків оголошувати їх поза циклом: тоді кожен хук реєструється один раз і виконується перед кожним тестом файлу, зокрема перед кожним згенерованим.
import { test } from '@playwright/test';
test.beforeEach(async () => {
console.log('beforeEach');
});
for (const size of ['S', 'M']) {
test(`size ${size}`, async () => {
console.log(`test ${size}`);
});
}Running 2 tests using 1 worker beforeEach test S ✓ 1 [chromium] › tests/hooks-outside.spec.ts:8:7 › size S (4ms) beforeEach test M ✓ 2 [chromium] › tests/hooks-outside.spec.ts:8:7 › size M (2ms) 2 passed (1.0s)
Окрема група describe на кожен набір даних
Буває, що підготовка залежить від параметра: для кожного пошукового запиту треба відкрити сторінку результатів, а потім зробити над нею кілька різних перевірок. Тоді в циклі створюють групу test.describe, а хук і тести кладуть усередину. Хук, оголошений у групі, бачить змінну поточної ітерації і виконується тільки для тестів своєї групи.
import { test, expect } from '@playwright/test';
const searchCases = [
{ query: 'mug', count: 2 },
{ query: 'tea', count: 3 },
];
for (const { query, count } of searchCases) {
test.describe(`search "${query}"`, () => {
// Хук всередині describe бачить параметр query з поточної ітерації
test.beforeEach(async ({ page }) => {
await page.goto(`/search?q=${query}`);
});
test('shows the number of products in the heading', async ({ page }) => {
await expect(page.getByRole('heading', { level: 1 })).toHaveText(`Found ${count} products`);
});
test('shows every product in the list', async ({ page }) => {
await expect(page.getByRole('listitem')).toHaveCount(count);
});
});
}Running 4 tests using 1 worker ✓ 1 [chromium] › tests/search-describe.spec.ts:15:9 › search "mug" › shows the number of products in the heading (353ms) ✓ 2 [chromium] › tests/search-describe.spec.ts:19:9 › search "mug" › shows every product in the list (211ms) ✓ 3 [chromium] › tests/search-describe.spec.ts:15:9 › search "tea" › shows the number of products in the heading (216ms) ✓ 4 [chromium] › tests/search-describe.spec.ts:19:9 › search "tea" › shows every product in the list (211ms) 4 passed (2.2s)
Тепер параметр стоїть у назві групи, а назви тестів усередині можуть бути однаковими для всіх груп: унікальним має бути повний шлях назви search "mug" › shows every product in the list, а не тільки остання частина. Документація також показує варіант з анонімною групою test.describe(() => { ... }); тоді параметр має бути в назвах самих тестів.
Хук, оголошений у циклі без групи, поводиться інакше, ніж може здатися.
Що виведе програма?
Скільки разів буде виведено beforeEach for ... перед кожним тестом?
import { test } from '@playwright/test';
for (const size of ['S', 'M', 'L']) {
test.beforeEach(async () => {
console.log(`beforeEach for ${size}`);
});
test(`size ${size}`, async () => {
console.log(`test ${size}`);
});
}Running 3 tests using 1 worker beforeEach for S beforeEach for M beforeEach for L test S ✓ 1 [chromium] › tests/hooks-wrong.spec.ts:8:7 › size S (5ms) beforeEach for S beforeEach for M beforeEach for L test M ✓ 2 [chromium] › tests/hooks-wrong.spec.ts:8:7 › size M (2ms) beforeEach for S beforeEach for M beforeEach for L test L ✓ 3 [chromium] › tests/hooks-wrong.spec.ts:8:7 › size L (2ms) 3 passed (1.1s)
Перед кожним тестом виконалися всі три хуки. Хук на верхньому рівні файлу належить усьому файлу, а не «найближчому» тесту під ним. Цикл тричі виконав test.beforeEach, тож у файлі зареєстровано три хуки, і кожен із них запускається перед кожним тестом файлу. Якби хук відкривав сторінку з параметром, тест size S спершу відкрив би сторінку для S, потім для M, потім для L і перевіряв би вже сторінку L. Щоб прив'язати хук до одного набору даних, його треба загорнути в test.describe, як у прикладі вище.
Теги для окремих кейсів
У темі 2 ми додавали теги (tags) до тестів і фільтрували запуск через --grep. У параметризованих тестах тег можна зробити частиною даних: тоді одні кейси потрапляють у швидкий набір @smoke, а інші ні. Деталь tag приймає як один рядок, так і масив рядків.
import { test, expect } from '@playwright/test';
type SearchCase = {
query: string;
heading: string;
tags?: string[];
};
const searchCases: SearchCase[] = [
{ query: 'mug', heading: 'Found 2 products', tags: ['@smoke'] },
{ query: 'tea', heading: 'Found 3 products' },
{ query: 'coffee', heading: 'Nothing found', tags: ['@smoke', '@negative'] },
];
for (const { query, heading, tags = [] } of searchCases) {
// tag приймає масив: кожен кейс отримує свій набір тегів
test(`search "${query}" shows "${heading}"`, { tag: tags }, async ({ page }) => {
await page.goto(`/search?q=${query}`);
await expect(page.getByRole('heading', { level: 1 })).toHaveText(heading);
});
}Running 3 tests using 1 worker ✓ 1 [chromium] › tests/search.spec.ts:17:7 › search "mug" shows "Found 2 products" @smoke (307ms) ✓ 2 [chromium] › tests/search.spec.ts:17:7 › search "tea" shows "Found 3 products" (245ms) ✓ 3 [chromium] › tests/search.spec.ts:17:7 › search "coffee" shows "Nothing found" @smoke @negative (220ms) 3 passed (2.0s)
Запуск лише кейсів з тегом @smoke:
npx playwright test tests/search.spec.ts --grep @smokeRunning 2 tests using 1 worker ✓ 1 [chromium] › tests/search.spec.ts:17:7 › search "mug" shows "Found 2 products" @smoke (333ms) ✓ 2 [chromium] › tests/search.spec.ts:17:7 › search "coffee" shows "Nothing found" @smoke @negative (231ms) 2 passed (1.8s)
Поле tags у типі необов'язкове (tags?: string[]), тому в деструктуризації для нього задано значення за замовчуванням tags = []: кейс без тегів отримує порожній масив, а не undefined. Так само --grep можна застосувати до будь-якої частини назви, наприклад --grep coffee запустить тільки кейс із запитом coffee. Це ще одна причина вписувати параметр у назву.
Де зберігати тестові дані
Поки кейсів два-три, масив у тестовому файлі цілком доречний. Коли їх стає більше або ті самі дані потрібні кільком тестам, дані виносять в окремі файли. Тест тоді описує тільки сценарій, а файл даних можна доповнювати, не торкаючись логіки. Розглянемо три формати, які згадує документація або які напряму підтримує Node.js.
Окремий модуль TypeScript
Найзручніший варіант для TypeScript-проєкту: тип і масив живуть в одному модулі, редактор підказує поля, а помилки в даних видно до запуску.
export type InvalidLoginCase = {
title: string;
email: string;
password: string;
error: string;
bug?: string; // заповнюємо, якщо кейс падає через відомий дефект
};
export const invalidLogins: InvalidLoginCase[] = [
{ title: 'empty email', email: '', password: 'Secret123', error: 'Email is required' },
{ title: 'email without @', email: 'user.example.com', password: 'Secret123', error: 'Enter a valid email' },
{
title: 'email without domain',
email: 'user@',
password: 'Secret123',
error: 'Enter a valid email',
bug: 'BUG-101: the form accepts an email without a domain',
},
{ title: 'empty password', email: 'user@example.com', password: '', error: 'Password is required' },
{ title: 'wrong password', email: 'user@example.com', password: 'wrong', error: 'Wrong email or password' },
];Поле bug знадобиться в розділі про Page Object: воно дозволяє залишити в даних кейс, який зараз падає через відомий дефект, і не ламати через нього прогін.
JSON
JSON зручний, коли дані готує не автор тестів (наприклад, аналітик) або їх генерує інший інструмент. Playwright уміє імпортувати JSON-файли напряму.
[
{ "query": "mug", "count": 2 },
{ "query": "tea", "count": 3 },
{ "query": "teapot", "count": 1 }
]import { test, expect } from '@playwright/test';
import searchCases from '../test-data/search-cases.json';
for (const { query, count } of searchCases) {
test(`json: "${query}" finds ${count}`, async ({ page }) => {
await page.goto(`/search?q=${query}`);
await expect(page.getByRole('listitem')).toHaveCount(count);
});
}Running 3 tests using 1 worker ✓ 1 [chromium] › tests/search-json.spec.ts:5:7 › json: "mug" finds 2 (284ms) ✓ 2 [chromium] › tests/search-json.spec.ts:5:7 › json: "tea" finds 3 (253ms) ✓ 3 [chromium] › tests/search-json.spec.ts:5:7 › json: "teapot" finds 1 (229ms) 3 passed (2.0s)
TypeScript виводить тип елементів з самого JSON: query стає string, count стає number. Щоб компілятор tsc взагалі дозволив такий імпорт, у tsconfig.json потрібен параметр resolveJsonModule.
CSV
Таблиці з кейсами часто ведуть в Excel або Google Sheets, і їх зручно експортувати в CSV. Тест-раннер Playwright працює в Node.js, тож файл можна прочитати модулем fs і розібрати будь-якою бібліотекою для CSV. Документація показує бібліотеку csv-parse, яку встановлюють командою npm install -D csv-parse.
query,count
mug,2
tea,3
teapot,1import fs from 'fs';
import path from 'path';
import { test, expect } from '@playwright/test';
import { parse } from 'csv-parse/sync';
type SearchRow = {
query: string;
count: string; // CSV не має типів: кожне значення приходить рядком
};
const rows: SearchRow[] = parse(
fs.readFileSync(path.join(__dirname, '../test-data/search-cases.csv')),
{ columns: true, skip_empty_lines: true },
);
for (const { query, count } of rows) {
test(`csv: "${query}" finds ${count}`, async ({ page }) => {
await page.goto(`/search?q=${query}`);
// Перетворюємо рядок на число, бо toHaveCount очікує number
await expect(page.getByRole('listitem')).toHaveCount(Number(count));
});
}Running 3 tests using 1 worker ✓ 1 [chromium] › tests/search-csv.spec.ts:17:7 › csv: "mug" finds 2 (358ms) ✓ 2 [chromium] › tests/search-csv.spec.ts:17:7 › csv: "tea" finds 3 (246ms) ✓ 3 [chromium] › tests/search-csv.spec.ts:17:7 › csv: "teapot" finds 1 (220ms) 3 passed (2.2s)
Тут працюють обидва правила зі збирання тестів. Файл читається синхронною функцією readFileSync (у модулі csv-parse/sync синхронний і сам розбір), тому до моменту виконання циклу дані вже є. Опція columns: true бере назви колонок з першого рядка і перетворює кожен наступний рядок на об'єкт { query: 'mug', count: '2' }. Зверніть увагу, що count — рядок: CSV не зберігає типи, і без Number(count) перевірка toHaveCount отримала б рядок замість числа. Через це в типі SearchRow поле count чесно описане як string. Шлях до файлу будується від __dirname, тобто від папки тестового файлу, а не від папки, з якої запущено команду, тому тест знаходить CSV незалежно від поточної директорії.
Параметризація разом із Page Object і фікстурами
Параметризація не замінює Page Object і фікстури, а добре з ними поєднується: Page Object інкапсулює кроки, фікстура готує сторінку, а цикл подає дані. Візьмемо LoginPage у стилі теми 5 і фікстуру loginPage у стилі теми 8, яка створює Page Object і одразу відкриває сторінку.
import type { Locator, Page } from '@playwright/test';
export class LoginPage {
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(private readonly page: Page) {
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Sign in' });
this.errorMessage = page.getByRole('alert');
}
async open(): Promise<void> {
await this.page.goto('/login');
}
async login(email: string, password: string): Promise<void> {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
}import { test as base, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
type PageFixtures = {
loginPage: LoginPage;
};
export const test = base.extend<PageFixtures>({
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await loginPage.open();
await use(loginPage);
},
});
export { expect };Параметризований тест тепер складається лише з даних і двох рядків сценарію. Кейс з полем bug позначається через test.fixme з умовою, знайомий з теми 2: такий тест не запускається і у звіті виглядає як пропущений, але залишається в даних і нагадує про дефект.
import { test, expect } from '../fixtures/test';
import { invalidLogins } from '../test-data/invalid-logins';
test.describe('login form validation', () => {
for (const { title, email, password, error, bug } of invalidLogins) {
test(`shows an error: ${title}`, async ({ loginPage }) => {
// Кейс з відомим дефектом не запускаємо, а позначаємо як fixme
test.fixme(bug !== undefined, bug);
await loginPage.login(email, password);
await expect(loginPage.errorMessage).toHaveText(error);
});
}
});Running 5 tests using 1 worker ✓ 1 [chromium] › tests/login-pom.spec.ts:6:9 › login form validation › shows an error: empty email (408ms) ✓ 2 [chromium] › tests/login-pom.spec.ts:6:9 › login form validation › shows an error: email without @ (309ms) - 3 [chromium] › tests/login-pom.spec.ts:6:9 › login form validation › shows an error: email without domain ✓ 4 [chromium] › tests/login-pom.spec.ts:6:9 › login form validation › shows an error: empty password (341ms) ✓ 5 [chromium] › tests/login-pom.spec.ts:6:9 › login form validation › shows an error: wrong password (263ms) 1 skipped 4 passed (2.8s)
Без test.fixme третій кейс падає: застосунок приймає user@ і відповідає Wrong email or password замість Enter a valid email. Коли дефект виправлять, достатньо прибрати поле bug з даних, і кейс знову почне виконуватися. Оскільки test.fixme викликається всередині тіла тесту, фікстура loginPage до цього моменту вже відпрацювала; для легкої фікстури це неважливо.
Поєднання з POM дає ще одну перевагу: якщо зміниться розмітка форми, правка буде в одному класі, а не в кожному кейсі. Дані, сценарій і опис сторінки змінюються незалежно.
Типові помилки в параметризованих тестах
Дві помилки трапляються настільки часто, що їх варто розібрати окремо. Обидві виглядають як «скорочення коду», але насправді погіршують діагностику.
Антипатерн: Умовна логіка всередині параметризованого тесту
Позитивні й негативні кейси входу зібрано в один масив, а різницю в очікуваннях вирішує if.
import { test, expect } from '@playwright/test';
const loginCases = [
{ email: 'user@example.com', password: 'Secret123', success: true, error: '' },
{ email: '', password: 'Secret123', success: false, error: 'Email is required' },
];
for (const { email, password, success, error } of loginCases) {
test(`login with "${email}"`, async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: 'Sign in' }).click();
if (success) {
await expect(page.getByRole('heading', { name: 'My account' })).toBeVisible();
} else {
await expect(page.getByRole('alert')).toHaveText(error);
}
});
}Running 2 tests using 1 worker ✓ 1 [chromium] › tests/if-else.spec.ts:9:7 › login with "user@example.com" (483ms) ✓ 2 [chromium] › tests/if-else.spec.ts:9:7 › login with "" (349ms) 2 passed (2.0s)
Тести проходять, але мають три проблеми. Назва login with "" нічого не каже про очікуваний результат. Тест містить дві різні перевірки, і з коду не видно, яка гілка виконується для конкретного кейсу; помилка в даних (наприклад, success: true для неправильного пароля) просто перемкне тест на іншу перевірку. Поле error: '' у позитивних кейсах існує лише для того, щоб масив мав однакову форму. Усе це ознаки того, що в одному масиві змішано два різні сценарії.
Як правильно
Розділити дані на два набори і написати два параметризовані сценарії, кожен зі своєю перевіркою і зрозумілими назвами.
import { test, expect } from '../fixtures/test';
import { invalidLogins } from '../test-data/invalid-logins';
const validAccounts = [
{ email: 'user@example.com', password: 'Secret123' },
{ email: 'manager@example.com', password: 'Manager123' },
];
test.describe('successful login', () => {
for (const { email, password } of validAccounts) {
test(`opens the account for ${email}`, async ({ loginPage, page }) => {
await loginPage.login(email, password);
await expect(page.getByRole('heading', { name: 'My account' })).toBeVisible();
});
}
});
test.describe('failed login', () => {
for (const { title, email, password, error, bug } of invalidLogins) {
test(`shows an error: ${title}`, async ({ loginPage }) => {
test.fixme(bug !== undefined, bug);
await loginPage.login(email, password);
await expect(loginPage.errorMessage).toHaveText(error);
});
}
});Running 7 tests using 1 worker ✓ 1 [chromium] › tests/split.spec.ts:11:9 › successful login › opens the account for user@example.com (471ms) ✓ 2 [chromium] › tests/split.spec.ts:11:9 › successful login › opens the account for manager@example.com (444ms) ✓ 3 [chromium] › tests/split.spec.ts:20:9 › failed login › shows an error: empty email (341ms) ✓ 4 [chromium] › tests/split.spec.ts:20:9 › failed login › shows an error: email without @ (327ms) - 5 [chromium] › tests/split.spec.ts:20:9 › failed login › shows an error: email without domain ✓ 6 [chromium] › tests/split.spec.ts:20:9 › failed login › shows an error: empty password (275ms) ✓ 7 [chromium] › tests/split.spec.ts:20:9 › failed login › shows an error: wrong password (265ms) 1 skipped 6 passed (3.7s)
Антипатерн: Цикл усередині одного тесту
Замість генерації тестів цикл поміщено в тіло одного тесту. У даних навмисно є помилка: для запиту tea очікується 2 товари, хоча їх 3.
import { test, expect } from '@playwright/test';
const searchCases = [
{ query: 'mug', count: 2 },
{ query: 'tea', count: 2 }, // помилка в даних: насправді товарів 3
{ query: 'teapot', count: 1 },
];
test('search works for all queries', async ({ page }) => {
for (const { query, count } of searchCases) {
await page.goto(`/search?q=${query}`);
await expect(page.getByRole('listitem')).toHaveCount(count);
}
});
Running 1 test using 1 worker
✘ 1 [chromium] › tests/loop-inside.spec.ts:9:5 › search works for all queries (5.4s)
1) [chromium] › tests/loop-inside.spec.ts:9:5 › search works for all queries ─────────────────────
Error: expect(locator).toHaveCount(expected) failed
Locator: getByRole('listitem')
Expected: 2
Received: 3
Timeout: 5000msЗвіт показує один тест замість трьох. Перша невдала перевірка зупинила функцію тесту, тому кейс teapot взагалі не виконався, і ми не знаємо, працює пошук за ним чи ні. У повідомленні про помилку немає запиту: щоб зрозуміти, що впав саме tea, доведеться рахувати ітерації або читати трейс. Окрім того, усі кейси виконуються послідовно на одній сторінці, тож стан після одного кейсу може вплинути на наступний, і такий тест не можна розподілити між воркерами.
Як правильно
Генерувати окремий тест на кожен кейс, як у попередніх розділах.
import { test, expect } from '@playwright/test';
const searchCases = [
{ query: 'mug', count: 2 },
{ query: 'tea', count: 2 }, // та сама помилка в даних
{ query: 'teapot', count: 1 },
];
for (const { query, count } of searchCases) {
test(`search "${query}" finds ${count}`, async ({ page }) => {
await page.goto(`/search?q=${query}`);
await expect(page.getByRole('listitem')).toHaveCount(count);
});
}Running 3 tests using 1 worker ✓ 1 [chromium] › tests/loop-fixed.spec.ts:10:7 › search "mug" finds 2 (329ms) ✘ 2 [chromium] › tests/loop-fixed.spec.ts:10:7 › search "tea" finds 2 (5.2s) ✓ 3 [chromium] › tests/loop-fixed.spec.ts:10:7 › search "teapot" finds 1 (332ms)
Тепер одразу видно, що впав саме кейс tea, а mug і teapot пройшли. Кожен кейс має власну сторінку, власний результат у звіті і власний трейс.
Параметризація на рівні projects
Опція як параметр усього набору тестів
Параметризація на рівні тесту множить один тест на набір даних. Але буває потрібно інше: прогнати всі тести (або цілу групу файлів) у кількох конфігураціях, наприклад під різними обліковими записами.
Проблема
Особистий кабінет має однакові перевірки для покупця і менеджера: показати, хто увійшов, і показати посилання на замовлення. Різниця лише в обліковому записі та в назві посилання (My orders чи All orders). Якщо параметризувати кожен тест циклом, у кожному файлі з'явиться той самий цикл за ролями, а додати нову роль означатиме пройти по всіх файлах.
Чому так стається
Роль тут не є даними конкретного тесту, це умова, у якій виконується весь набір тестів. Такі умови в Playwright описують у конфігурації: у темі 2 ми бачили, що projects дозволяє запустити ті самі тести з різними налаштуваннями use (наприклад, у різних браузерах). Бракує лише способу передати через use власне значення, якого немає серед вбудованих опцій.
Рішення
Оголосити власну опцію (option) — фікстуру з прапорцем { option: true }, як у темі 8, — і задати її значення окремо в кожному project. Тести отримують опцію так само, як будь-яку фікстуру, а Playwright запускає кожен тест один раз для кожного project.
Коли застосовувати
Коли весь набір тестів (або великий його фрагмент) має виконуватися в кількох конфігураціях: під різними ролями, з різними мовами інтерфейсу, проти різних стендів. Якщо параметр стосується одного-двох тестів, простіше параметризувати їх циклом.
Спершу оголошуємо опцію. Значення за замовчуванням задається в кортежі першим елементом, а { option: true } дозволяє перевизначити його в конфігурації. У тому ж test.extend перевизначаємо вбудовану фікстуру page: вона входить в систему під обліковим записом з опції, і тест отримує сторінку вже після входу. Саме такий прийом показано в документації з параметризації.
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
export type Account = {
email: string;
password: string;
ordersLink: string; // яке посилання очікуємо в меню для цього акаунта
};
export type TestOptions = {
account: Account;
};
export const test = base.extend<TestOptions>({
// Опція зі значенням за замовчуванням; конфіг може його перевизначити
account: [
{ email: 'user@example.com', password: 'Secret123', ordersLink: 'My orders' },
{ option: true },
],
// Перевизначаємо page: тест отримує сторінку вже після входу
page: async ({ page, account }, use) => {
const loginPage = new LoginPage(page);
await loginPage.open();
await loginPage.login(account.email, account.password);
await use(page);
},
});
export { expect } from '@playwright/test';Тести нічого не знають про ролі. Вони просто беруть фікстуру account і перевіряють сторінку відповідно до неї.
import { test, expect } from '../fixtures/account-test';
test('shows who is signed in', async ({ page, account }) => {
await expect(page.getByText(`Signed in as ${account.email}`)).toBeVisible();
});
test('shows the orders link for the account', async ({ page, account }) => {
await expect(page.getByRole('link', { name: account.ordersLink })).toBeVisible();
});У конфігурації оголошуємо два projects. Узагальнений виклик defineConfig<TestOptions> повідомляє TypeScript про нову опцію, тож у use редактор підкаже поле account і перевірить його структуру.
import { defineConfig, devices } from '@playwright/test';
import type { TestOptions } from './fixtures/account-test';
export default defineConfig<TestOptions>({
testDir: './tests',
use: {
baseURL: 'http://localhost:3000',
...devices['Desktop Chrome'],
},
projects: [
{
name: 'customer',
// account не вказано: працює значення за замовчуванням з фікстури
},
{
name: 'manager',
use: {
account: { email: 'manager@example.com', password: 'Manager123', ordersLink: 'All orders' },
},
},
],
});Running 4 tests using 1 worker ✓ 1 [customer] › tests/account.spec.ts:3:5 › shows who is signed in (611ms) ✓ 2 [customer] › tests/account.spec.ts:7:5 › shows the orders link for the account (396ms) ✓ 3 [manager] › tests/account.spec.ts:3:5 › shows who is signed in (515ms) ✓ 4 [manager] › tests/account.spec.ts:7:5 › shows the orders link for the account (396ms) 4 passed (4.2s)
Два тести у файлі дали чотири запуски: кожен тест виконався в кожному project, і назва project видно у квадратних дужках. Механізм такий: значення опції визначається для конкретного project, фікстура page отримує його як залежність, тож однаковий код тесту працює з різними даними. Значення з use конкретного project має пріоритет над загальним use і над значенням за замовчуванням у фікстурі. Запустити тільки одну конфігурацію можна знайомим прапорцем --project=manager.
Обидва рівні параметризації множаться: якщо файл генерує 5 тестів циклом, а конфігурація має 3 projects, буде 15 запусків. Це варто пам'ятати, плануючи час прогону.
Попередній перегляд: projects для різних браузерів (тема 10)
Найпоширеніше застосування projects — запуск тих самих тестів у Chromium, Firefox і WebKit, де «параметром» є пристрій з devices. Такі projects можна поєднувати з власними опціями. Налаштування кросбраузерного запуску і того, як projects розподіляються між воркерами, детально розглядаються в темі 10 «Parallelism and Cross-browser».
Порівняймо два рівні. Параметризація на рівні тесту живе в тестовому файлі, генерує різні назви тестів і підходить для даних, які стосуються конкретного сценарію: значень полів, пошукових запитів, очікуваних повідомлень. Параметризація на рівні projects живе в конфігурації, не змінює назви тестів (розрізняє їх назва project) і підходить для умов, у яких виконується весь набір: ролі, локалі, середовища, браузери.
Змінні середовища
Параметри, які приходять ззовні
Деякі параметри не можна або не варто записувати в код. Паролі та токени не повинні потрапляти в репозиторій. Адреса стенда відрізняється для локального запуску і для тестового сервера. Такі значення передають через змінні середовища (environment variables): це пари «назва=значення», які операційна система передає процесу під час запуску. У Node.js вони доступні в об'єкті process.env.
Встановити змінну можна прямо в команді запуску. Синтаксис залежить від оболонки, тому документація Playwright наводить кілька варіантів:
# Bash (Linux, macOS, Git Bash)
USER_EMAIL=user@example.com USER_PASSWORD=Secret123 npx playwright test
# PowerShell
$env:USER_EMAIL="user@example.com"
$env:USER_PASSWORD="Secret123"
npx playwright test
# Batch (cmd.exe)
set USER_EMAIL=user@example.com
set USER_PASSWORD=Secret123
npx playwright testЗмінні читаються як у тестах, так і в конфігурації. Типовий приклад — адреса стенда в baseURL: документація показує варіант із прапорцем STAGING=1, а нижче ми передаємо саму адресу через BASE_URL зі значенням за замовчуванням для локального запуску.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
use: {
// Без змінної BASE_URL тести йдуть на локальний стенд
baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
});Оператор ?? підставляє праве значення, лише коли ліве дорівнює undefined або null, тобто коли змінну не передано.
Тип string | undefined
У TypeScript кожна змінна з process.env має тип string | undefined, бо змінної під час запуску може не бути. Якщо передати її прямо в метод, що очікує рядок, tsc повідомить про помилку.
import { test } from '@playwright/test';
test('signs in', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.USER_EMAIL);
await page.getByLabel('Password').fill(process.env.USER_PASSWORD);
});tests/login-env-raw.spec.ts(5,39): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'. Type 'undefined' is not assignable to type 'string'. tests/login-env-raw.spec.ts(6,42): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'. Type 'undefined' is not assignable to type 'string'.
Це вивід npx tsc -p tsconfig.json --noEmit. Раннер Playwright, як ми вже знаємо, типи не перевіряє і запустить такий тест. Якщо змінна не задана, тест упаде лише на кроці заповнення поля з повідомленням locator.fill: value: expected string, got undefined, яке не пояснює, якої змінної бракує і де її задати. Краще перевіряти змінні явно і падати з зрозумілим повідомленням. Для цього зручно мати маленьку функцію-помічника.
// Повертає значення змінної середовища або одразу пояснює, чого бракує
export function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Environment variable ${name} is not set. Add it to .env or pass it in the command line`);
}
return value;
}Після перевірки if (!value) TypeScript звужує тип до string, тому функція чесно повертає рядок, і fill() приймає його без помилок типів.
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { requireEnv } from '../utils/env';
test('user signs in with credentials from the environment', async ({ page }) => {
const email = requireEnv('USER_EMAIL');
const password = requireEnv('USER_PASSWORD');
const loginPage = new LoginPage(page);
await loginPage.open();
await loginPage.login(email, password);
await expect(page.getByRole('heading', { name: 'My account' })).toBeVisible();
});Запуск без змінних дає повідомлення, з якого одразу зрозуміло, що робити:
Running 1 test using 1 worker
✘ 1 [chromium] › tests/login-env.spec.ts:5:5 › user signs in with credentials from the environment (1.8s)
1) [chromium] › tests/login-env.spec.ts:5:5 › user signs in with credentials from the environment
Error: Environment variable USER_EMAIL is not set. Add it to .env or pass it in the command lineА запуск зі змінними в команді проходить:
USER_EMAIL=user@example.com USER_PASSWORD=Secret123 npx playwright testRunning 1 test using 1 worker ✓ 1 [chromium] › tests/login-env.spec.ts:5:5 › user signs in with credentials from the environment (898ms) 1 passed (2.4s)
Файл .env і пакет dotenv
Писати змінні в кожній команді незручно. Документація Playwright пропонує зберігати їх у файлі .env і завантажувати пакетом dotenv (встановлення: npm install -D dotenv) прямо в конфігурації.
BASE_URL=http://localhost:3000
USER_EMAIL=user@example.com
USER_PASSWORD=Secret123import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';
import path from 'path';
// Читаємо .env поруч із конфігом до того, як конфіг використає process.env
dotenv.config({ path: path.resolve(__dirname, '.env'), quiet: true });
export default defineConfig({
testDir: './tests',
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
});Running 1 test using 1 worker ✓ 1 [chromium] › tests/login-env.spec.ts:5:5 › user signs in with credentials from the environment (555ms) 1 passed (1.9s)
Розберімо, що тут відбувається. Виклик dotenv.config() читає файл і записує пари з нього в process.env. Він стоїть на початку конфігу, до defineConfig, бо об'єкт конфігурації будується одразу під час завантаження файлу, і process.env.BASE_URL має вже містити значення. Шлях будується від __dirname, щоб файл знаходився незалежно від папки, з якої запущено команду. Опція quiet: true вимикає службове повідомлення, яке сучасні версії dotenv за замовчуванням друкують при кожному завантаженні; без неї у виводі з'являться рядки на кшталт ◇ injected env (3) from .env.
Конфіг, як і тестові файли, завантажується і головним процесом, і кожним воркером. Змінні, які головний процес записав у process.env, воркери успадковують, тому тести бачать значення з .env без додаткових дій.
Важлива деталь пріоритету: dotenv за замовчуванням не перезаписує змінні, які вже є в середовищі. Якщо у .env записано один пароль, а в команді запуску інший, переможе значення з команди. Ми перевірили це, записавши в .env неправильний пароль і передавши правильний у команді: тест пройшов. Саме так зазвичай і потрібно: .env дає зручні локальні значення, а сервер збірки чи колега можуть перевизначити їх, не редагуючи файл.
Кілька середовищ
Коли стендів кілька, зручно мати окремий файл на кожен і вибирати його однією змінною. Назва середовища передається в команді, а конфіг будує з неї шлях до файлу.
// TEST_ENV=staging -> .env.staging; без змінної -> .env.local
const testEnv = process.env.TEST_ENV ?? 'local';
dotenv.config({ path: path.resolve(__dirname, `.env.${testEnv}`), quiet: true });# Локальний стенд: читається .env.local
npx playwright test
# Тестовий стенд: читається .env.staging
TEST_ENV=staging npx playwright testРешта конфігу не змінюється: baseURL і облікові дані беруться з process.env, а звідки вони туди потрапили, вирішує одна змінна.
Міні-задачі
Міні-задача: Задача 1. Набір перевірок пошуку
Магазин має сторінку пошуку /search?q=<запит>. Заголовок першого рівня показує Found N products (для одного товару Found 1 product) або Nothing found, а знайдені товари виводяться пунктами списку. Відомі такі кейси: запит mug знаходить 2 товари, tea знаходить 3, teapot знаходить 1, coffee нічого не знаходить. Кейси mug і coffee входять у швидкий набір @smoke.
Потрібно винести дані в типізований модуль test-data/search-cases.ts. Для кожного кейсу створити окрему групу з хуком, який відкриває сторінку результатів, і двома тестами: перевірка тексту заголовка і перевірка кількості пунктів списку. Назви мають бути унікальними й інформативними, а команда npx playwright test --grep @smoke має запускати рівно 4 тести.
Підказка
Теги можна задати не тільки на тесті, а й на групі test.describe(title, { tag }, callback): тоді їх отримують усі тести групи. Для кейсу без тегів знадобиться значення за замовчуванням у деструктуризації. Кількість елементів перевіряє toHaveCount, і 0 теж допустиме значення.
Показати рішення
Дані з типом в окремому модулі. Очікуваний заголовок зберігаємо як є, а не будуємо в тесті з кількості: так тест перевіряє реальний текст, зокрема однину для одного товару.
export type SearchCase = {
query: string;
heading: string;
count: number;
tags?: string[];
};
export const searchCases: SearchCase[] = [
{ query: 'mug', heading: 'Found 2 products', count: 2, tags: ['@smoke'] },
{ query: 'tea', heading: 'Found 3 products', count: 3 },
{ query: 'teapot', heading: 'Found 1 product', count: 1 },
{ query: 'coffee', heading: 'Nothing found', count: 0, tags: ['@smoke'] },
];Тест: цикл створює групу на кожен кейс, тег ставиться на групу, хук усередині групи відкриває сторінку з параметром.
import { test, expect } from '@playwright/test';
import { searchCases } from '../test-data/search-cases';
for (const { query, heading, count, tags = [] } of searchCases) {
// Теги на describe успадковують усі тести всередині групи
test.describe(`search "${query}"`, { tag: tags }, () => {
test.beforeEach(async ({ page }) => {
await page.goto(`/search?q=${query}`);
});
test(`shows heading "${heading}"`, async ({ page }) => {
await expect(page.getByRole('heading', { level: 1 })).toHaveText(heading);
});
test(`product list has length ${count}`, async ({ page }) => {
await expect(page.getByRole('listitem')).toHaveCount(count);
});
});
}Running 8 tests using 1 worker ✓ 1 [chromium] › tests/search-task.spec.ts:11:9 › search "mug" › shows heading "Found 2 products" @smoke (370ms) ✓ 2 [chromium] › tests/search-task.spec.ts:15:9 › search "mug" › product list has length 2 @smoke (254ms) ✓ 3 [chromium] › tests/search-task.spec.ts:11:9 › search "tea" › shows heading "Found 3 products" (216ms) ✓ 4 [chromium] › tests/search-task.spec.ts:15:9 › search "tea" › product list has length 3 (193ms) ✓ 5 [chromium] › tests/search-task.spec.ts:11:9 › search "teapot" › shows heading "Found 1 product" (187ms) ✓ 6 [chromium] › tests/search-task.spec.ts:15:9 › search "teapot" › product list has length 1 (180ms) ✓ 7 [chromium] › tests/search-task.spec.ts:11:9 › search "coffee" › shows heading "Nothing found" @smoke (187ms) ✓ 8 [chromium] › tests/search-task.spec.ts:15:9 › search "coffee" › product list has length 0 @smoke (186ms) 8 passed (3.4s)
Швидкий набір:
npx playwright test tests/search-task.spec.ts --grep @smokeRunning 4 tests using 1 worker ✓ 1 [chromium] › tests/search-task.spec.ts:11:9 › search "mug" › shows heading "Found 2 products" @smoke (342ms) ✓ 2 [chromium] › tests/search-task.spec.ts:15:9 › search "mug" › product list has length 2 @smoke (248ms) ✓ 3 [chromium] › tests/search-task.spec.ts:11:9 › search "coffee" › shows heading "Nothing found" @smoke (257ms) ✓ 4 [chromium] › tests/search-task.spec.ts:15:9 › search "coffee" › product list has length 0 @smoke (268ms) 4 passed (2.4s)
Чому рішення саме таке. Дані не змішані з логікою, і новий кейс додається одним рядком у модулі. Хук у групі гарантує, що кожна перевірка працює зі сторінкою свого запиту. Повний шлях назви унікальний, бо запит стоїть у назві групи. Тег на групі не довелося повторювати на кожному тесті, і обидва тести кейсу потрапляють у @smoke разом.
Міні-задача: Задача 2. Ролі в projects, паролі в .env
Візьміть фікстуру account і тести account.spec.ts з розділу про projects. Зараз пароль менеджера записано прямо в playwright.config.ts. Перепишіть конфігурацію так, щоб облікові дані обох ролей бралися зі змінних CUSTOMER_EMAIL, CUSTOMER_PASSWORD, MANAGER_EMAIL, MANAGER_PASSWORD, які завантажуються з .env. Якщо змінної немає, запуск має зупинитися з повідомленням, яка саме змінна відсутня. Перевірте запуск обох projects і окремо --project=manager.
Підказка
Функція requireEnv працює і в конфігурації: це звичайний TypeScript-модуль. Подумайте, у який момент виконується код конфігурації і що це означає для змінних, потрібних лише одному project.
Показати рішення
CUSTOMER_EMAIL=user@example.com
CUSTOMER_PASSWORD=Secret123
MANAGER_EMAIL=manager@example.com
MANAGER_PASSWORD=Manager123import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';
import path from 'path';
import type { TestOptions } from './fixtures/account-test';
import { requireEnv } from './utils/env';
dotenv.config({ path: path.resolve(__dirname, '.env'), quiet: true });
export default defineConfig<TestOptions>({
testDir: './tests',
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
...devices['Desktop Chrome'],
},
projects: [
{
name: 'customer',
use: {
account: {
email: requireEnv('CUSTOMER_EMAIL'),
password: requireEnv('CUSTOMER_PASSWORD'),
ordersLink: 'My orders',
},
},
},
{
name: 'manager',
use: {
account: {
email: requireEnv('MANAGER_EMAIL'),
password: requireEnv('MANAGER_PASSWORD'),
ordersLink: 'All orders',
},
},
},
],
});Running 4 tests using 1 worker ✓ 1 [customer] › tests/account.spec.ts:3:5 › shows who is signed in (612ms) ✓ 2 [customer] › tests/account.spec.ts:7:5 › shows the orders link for the account (453ms) ✓ 3 [manager] › tests/account.spec.ts:3:5 › shows who is signed in (527ms) ✓ 4 [manager] › tests/account.spec.ts:7:5 › shows the orders link for the account (411ms) 4 passed (4.2s)
Тільки менеджер:
npx playwright test --project=managerRunning 2 tests using 1 worker ✓ 1 [manager] › tests/account.spec.ts:3:5 › shows who is signed in (628ms) ✓ 2 [manager] › tests/account.spec.ts:7:5 › shows the orders link for the account (423ms) 2 passed (2.3s)
Без файлу .env запуск зупиняється ще до старту тестів (перший рядок виводу):
Error: Environment variable CUSTOMER_EMAIL is not set. Add it to .env or pass it in the command line
Тут видно наслідок того, що requireEnv викликається в конфігурації. Об'єкт конфігу будується повністю при завантаженні файлу, разом з усіма projects. Тому навіть запуск --project=manager без змінних покупця зупиниться з тією самою помилкою про CUSTOMER_EMAIL: ми перевірили це, передавши в команді лише змінні менеджера. Для команди, де кожен має повний .env, це зручно: забута змінна виявляється одразу. Якщо ж потрібно запускати одну роль без даних іншої, перевірку переносять у фікстуру, яка виконується тільки під час тесту.
Підсумок
Параметризація відокремлює дані від сценарію. На рівні тесту це звичайний цикл навколо test() або test.describe(), де кожна ітерація реєструє окремий тест з унікальною назвою. Цикл виконується у фазі збирання, причому файл завантажують і головний процес, і кожен воркер, тому дані мають бути синхронними й однаковими при кожному завантаженні, а випадкові значення генеруються всередині тесту. Хуки для всіх кейсів оголошуються поза циклом, а хуки для конкретного кейсу кладуться в групу describe усередині циклу. Дані зручно тримати в типізованому модулі, у JSON або в CSV; у CSV усі значення рядкові. На рівні projects параметром стає опція з { option: true }, а кожен project задає її значення, і всі тести виконуються в кожній конфігурації. Змінні середовища приносять у тести те, чого не має бути в коді: адреси стендів і секрети. Їх читають з process.env, перевіряють явно і зберігають локально у .env, який не потрапляє в репозиторій.
Перевір себе
Самоперевірка
Чому всі тести з циклу у звіті мають однакове місце в коді, наприклад
login-negative.spec.ts:20:7?Скільки рядків з галочками було у звіті і чи міг би один тест дати кілька таких рядків?
Правильно. Місце в коді вказує на рядок, де викликано
test(), а цикл просто виконує цей рядок кілька разів з різними даними.Чи зміниться це число, якщо додати в масив ще один кейс?
Тест у циклі має назву `
registers ${Math.random()}`. Що станеться під час запуску?Скільки разів завантажується тестовий файл і чи однакове значення поверне
Math.random()щоразу?Чи можуть два виклики
Math.random()повернути однакове значення так, щоб це стабільно повторювалося?Правильно. Головний процес і воркер завантажують файл окремо, отримують різні випадкові значення, і воркер не знаходить тест з назвою, яку зареєстрував головний процес.
У файлі в циклі з трьох ітерацій без
test.describeоголошеноtest.beforeEachіtest. Скільки хуків виконається перед кожним тестом?Чи знає хук верхнього рівня, поруч із яким викликом
test()його оголосили?Правильно. Хук без групи належить усьому файлу, тож три виклики
test.beforeEachдають три хуки, і всі вони виконуються перед кожним тестом.Що показав
console.logу прикладі з розміром S, M, L?Дані завантажуються з CSV з колонками
query,count. Чому передtoHaveCountпотрібенNumber(count)?Що змінює опція
columns— типи значень чи форму рядків результату?Правильно. Формат CSV — це текст, і розбирач без додаткових налаштувань не вгадує типи, тож число треба перетворити явно.
Чи довелося б викликати
Number, якби дані прийшли з JSON?Коли доречніше параметризувати на рівні projects, ніж циклом у тестовому файлі?
Ці значення стосуються одного сценарію чи всього набору тестів?
Чи впливає спосіб параметризації на швидкість одного тесту, і що відбувається з кількістю запусків, коли projects стає більше?
Правильно. Опція з
{ option: true }задається в кожному project, і всі тести виконуються в кожній конфігурації без циклів у файлах.У
.envзаписаноUSER_PASSWORD=wrong, а тест запускають командоюUSER_PASSWORD=Secret123 npx playwright test. Яке значення побачить тест, якщо конфіг викликаєdotenv.config()з налаштуваннями за замовчуванням?Чи перезаписує
dotenvза замовчуванням змінні, які вже є в середовищі?Правильно. Значення з команди запуску вже є в
process.envна момент викликуdotenv.config(), тому файл його не перезаписує.Чи є в Node.js механізм, який очищує змінну при конфлікті джерел?
Чому
page.getByLabel('Email').fill(process.env.USER_EMAIL)— погана ідея навіть тоді, коли змінна точно задана?Звідки тоді тест у прикладі з
requireEnvотримав облікові дані?Правильно. Явна перевірка через функцію на кшталт
requireEnvзвужує тип доstringі пояснює, якої змінної бракує.Що саме потрапляє в репозиторій — код тесту чи значення змінних під час запуску?
Джерела
- Parameterize testsпараметризація на рівні тесту, хуки поза циклом і в
describe, параметризовані projects з опціями, змінні середовища,.envзdotenv, тести з CSV - Fixtures: fixtures-optionsопції з
{ option: true }і перевизначення вбудованих фікстур - Projectsналаштування
projectsі запуск окремого project через--project - Annotationsтеги тестів і груп,
test.fixmeз умовою - Command lineпрапорці
--grep,--list,--project,--workers - TypeScriptPlaywright не перевіряє типи, рекомендація запускати
tsc --noEmit - Test API: test.describe, test.beforeEachсигнатури
test,test.describeз деталямиtag, хуки - Best Practicesізоляція тестів і локатори, орієнтовані на користувача
- Release notesактуальна версія Playwright 1.63
- csv-parseопції
columnsіskip_empty_lines, синхронний APIcsv-parse/sync - dotenv на npm
dotenv.config({ path }), опціяquiet, правило неперезапису наявних змінних - Node.js: process.envзмінні середовища процесу Node.js
- TypeScript: resolveJsonModuleімпорт JSON-модулів з виведенням типів