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 используй только когда настройка действительно дорогостоящая и безопасна для разделения между тестами.

→ See also: Фикстуры Playwright: от встроенных до кастомных | Изоляция тестов: почему каждый тест Playwright должен быть stateless | Практики автоматизации тестирования, которые реально важны