test.beforeEach запускает настройку перед каждым тестом в своей области видимости: код логина написанный один раз внутри блока describe обслуживает каждый тест внутри него, не затрагивая тесты снаружи. test.beforeAll запускается один раз для всей группы, не перед каждым тестом: токен аутентификации который он создаёт разделяется между всеми тестами в файле, что вызывает интерференцию при параллельном запуске. Эта статья разбирает правила области видимости которые делают блоки describe полезными, порядок выполнения вложенных хуков, когда test.beforeAll уместен и когда опасен, и поведение test.only которое молча ломает CI если попасть в коммит.
Базовый тест
До добавления структуры одиночный тест выглядит так:
import { test, expect } from '@playwright/test';
test('user can log in', async ({ page }) => {
await page.goto('/login');
await page.fill('[data-testid="email"]', 'user@test.com');
await page.fill('[data-testid="password"]', 'ValidPass1');
await page.click('[data-testid="submit"]');
await expect(page).toHaveURL('/dashboard');
});Просто. Но если есть 10 тестов которые все начинаются с одинаковых шагов логина, это 10 дублированных блоков. Когда флоу логина меняется, обновляешь 10 мест вместо одного.
test.beforeEach: запуск перед каждым тестом
beforeEach запускает настроечный код перед каждым тестом в своей области видимости. Самые частые сценарии: перейти на страницу или залогиниться.
import { test, expect } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.goto('/login');
await page.fill('[data-testid="email"]', 'user@test.com');
await page.fill('[data-testid="password"]', 'ValidPass1');
await page.click('[data-testid="submit"]');
await page.waitForURL('/dashboard');
});
test('dashboard shows user name', async ({ page }) => {
// Уже залогинен и на /dashboard
await expect(page.getByTestId('user-name')).toContainText('Test User');
});
test('user can access settings', async ({ page }) => {
// Тоже уже залогинен
await page.getByTestId('settings-link').click();
await expect(page).toHaveURL('/settings');
});Каждый тест начинает с уже залогиненного пользователя на дашборде. Никакого дублирования.
test.afterEach: очистка после каждого теста
afterEach запускается после каждого теста вне зависимости от того прошёл он или упал. Используй для очистки которая должна происходить после каждого теста.
test.afterEach(async ({ page }, testInfo) => {
// Скриншот при падении (Playwright также умеет делать это через конфиг)
if (testInfo.status !== testInfo.expectedStatus) {
await page.screenshot({ path: `screenshots/${testInfo.title}.png` });
}
});Или для API-очистки:
let createdUserId: number;
test.beforeEach(async ({ request }) => {
const response = await request.post('/api/users', {
data: { email: 'temp@test.com', password: 'Pass1' },
});
const body = await response.json();
createdUserId = body.id;
});
test.afterEach(async ({ request }) => {
// Удаляем пользователя созданного при настройке
await request.delete(`/api/users/${createdUserId}`);
});test.describe: группировка связанных тестов
test.describe создаёт именованную группу тестов. Полезно для группировки тестов по фиче или странице, для применения beforeEach/afterEach только к подмножеству тестов и для вложения связанных сценариев.
import { test, expect } from '@playwright/test';
test.describe('Login page', () => {
test('shows email and password fields', async ({ page }) => {
await page.goto('/login');
await expect(page.getByTestId('email')).toBeVisible();
await expect(page.getByTestId('password')).toBeVisible();
});
test('shows error on wrong password', async ({ page }) => {
await page.goto('/login');
await page.fill('[data-testid="email"]', 'user@test.com');
await page.fill('[data-testid="password"]', 'WrongPass');
await page.click('[data-testid="submit"]');
await expect(page.getByTestId('error-message')).toBeVisible();
});
});
test.describe('Dashboard', () => {
test.beforeEach(async ({ page }) => {
// Логин перед каждым тестом дашборда
await page.goto('/login');
await page.fill('[data-testid="email"]', 'user@test.com');
await page.fill('[data-testid="password"]', 'ValidPass1');
await page.click('[data-testid="submit"]');
await page.waitForURL('/dashboard');
});
test('shows user name', async ({ page }) => {
await expect(page.getByTestId('user-name')).toBeVisible();
});
test('shows recent orders', async ({ page }) => {
await expect(page.getByTestId('orders-section')).toBeVisible();
});
});beforeEach с логином применяется только к блоку Dashboard. Тесты Login page не затронуты.
test.beforeAll и test.afterAll
beforeAll запускается один раз перед всеми тестами в своей области видимости (не перед каждым). afterAll запускается один раз после всех.
Когда это нужно: дорогостоящая настройка которую достаточно сделать один раз. Например создание тестового пользователя через API, сидирование базы данных или запуск сервера.
import { test, expect, request as playwrightRequest } from '@playwright/test';
let authToken: string;
test.beforeAll(async () => {
// Создаём токен авторизации один раз для всех тестов в файле
const ctx = await playwrightRequest.newContext();
const response = await ctx.post('/api/auth/login', {
data: { email: 'admin@test.com', password: 'AdminPass1' },
});
const body = await response.json();
authToken = body.token;
await ctx.dispose();
});
test('admin can view all users', async ({ request }) => {
const response = await request.get('/api/admin/users', {
headers: { Authorization: `Bearer ${authToken}` },
});
expect(response.status()).toBe(200);
});
test('admin can delete a user', async ({ request }) => {
// Тоже использует authToken — разделяется между всеми тестами
});beforeAll и afterAll работают в общем контексте. Изменения разделяемого состояния сохраняются между тестами, что при небрежном использовании вызывает флакающие тесты. Для большинства настроек предпочитай beforeEach.
Вложенные блоки describe
Блоки describe можно вкладывать для создания иерархии:
test.describe('Checkout flow', () => {
test.beforeEach(async ({ page }) => {
await loginAsUser(page);
await addItemToCart(page, 'product-123');
});
test.describe('with valid card', () => {
test.beforeEach(async ({ page }) => {
await fillShippingAddress(page);
await fillValidCard(page, '4242 4242 4242 4242');
});
test('completes purchase', async ({ page }) => {
await page.getByTestId('place-order').click();
await expect(page.getByTestId('order-confirmation')).toBeVisible();
});
test('sends confirmation email', async ({ page }) => {
// ...
});
});
test.describe('with invalid card', () => {
test('shows error message', async ({ page }) => {
await fillValidCard(page, '0000 0000 0000 0000');
await page.getByTestId('place-order').click();
await expect(page.getByTestId('payment-error')).toBeVisible();
});
});
});Порядок выполнения хуков для вложенного теста:
1. Внешний beforeEach (логин + добавление в корзину)
2. Внутренний beforeEach (заполнение адреса + данных карты)
3. Сам тест
4. Внутренний afterEach (если есть)
5. Внешний afterEach (если есть)
test.skip и test.only
Два модификатора полезных при разработке:
// Пропустить тест (помечается как skipped, не как failed)
test.skip('feature not implemented yet', async ({ page }) => {
// ...
});
// Запустить только этот тест (игнорирует все остальные в файле)
test.only('debugging this specific case', async ({ page }) => {
// ...
});test.only нельзя коммитить в основную ветку: он делает так что весь CI-сьют «падает» запуская лишь один тест.
Условный пропуск подходит для тестов специфичных для окружения:
test('admin panel', async ({ page }) => {
test.skip(process.env.ENV === 'production', 'Skipped in production');
// ...
});Как тесты называются в отчётах
Название теста в отчёте объединяет метку describe и метку test:
test.describe('Login page', () => {
test('shows error on wrong password', async ({ page }) => { ... });
});
// В отчёте: "Login page > shows error on wrong password"Пиши описательные названия. Поблагодаришь себя когда CI-прогон покажет 3 падения и нужно будет разобраться в них без открытия кода.
Пример полной структуры файла
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
test.describe('User authentication', () => {
let loginPage: LoginPage;
test.beforeEach(async ({ page }) => {
loginPage = new LoginPage(page);
await loginPage.navigate();
});
test.describe('Valid credentials', () => {
test('redirects to dashboard', async ({ page }) => {
await loginPage.login('user@test.com', 'ValidPass1');
await expect(page).toHaveURL('/dashboard');
});
test('sets auth cookie', async ({ page }) => {
await loginPage.login('user@test.com', 'ValidPass1');
const cookies = await page.context().cookies();
expect(cookies.some(c => c.name === 'auth_token')).toBe(true);
});
});
test.describe('Invalid credentials', () => {
test('wrong password shows error', async () => {
await loginPage.login('user@test.com', 'WrongPass');
await expect(loginPage.errorMessage).toBeVisible();
});
test('empty email shows validation error', async () => {
await loginPage.login('', 'ValidPass1');
await expect(loginPage.emailError).toContainText('required');
});
});
});Шпаргалка
| Хук | Когда запускается | Для чего |
|-----|-------------------|----------|
| test.beforeEach | Перед каждым тестом в области видимости | Навигация, логин, сброс состояния |
| test.afterEach | После каждого теста в области видимости | Очистка, скриншоты при падении |
| test.beforeAll | Один раз перед всеми тестами в области | Дорогостоящая одноразовая настройка |
| test.afterAll | Один раз после всех тестов в области | Одноразовый teardown |
| test.describe | (группировка, не хук) | Организация тестов, область видимости хуков |
Начинай с test.beforeEach для большинства настроек. Добавляй test.describe чтобы группировать связанные тесты. test.beforeAll используй только когда настройка действительно дорогостоящая и безопасна для разделения между тестами.