Playwright автоматически создаёт свежий BrowserContext для каждого теста, так что состояние браузера (куки, localStorage, сессия) уже изолировано. Состояние приложения нет: let testUserId на уровне модуля, записанный одним тестом и прочитанный следующим, сломается как только параллельный воркер запустит эти тесты в другом порядке. Эта статья разбирает паттерны падений ответственных за большинство ошибок изоляции, подход с фикстурой createUser которая делает очистку безусловной, как storageState изолирует аутентификацию без разделения живой сессии, и сравнение --workers=1 и --workers=4 которое выявляет скрытое разделяемое состояние.

Что на самом деле означает изоляция тестов

Изолированный тест не делает предположений о мире до своего запуска и не оставляет следов после завершения. Каждый тест подготавливает всё что ему нужно, делает свою работу, и окружение после завершения теста идентично окружению до его начала.

Это звучит очевидно пока не видишь что «состояние» реально охватывает в живом проекте. Есть состояние браузера (куки, localStorage, данные сессии), состояние приложения (записи базы данных, учётные записи, флаги фич) и состояние тестового кода (переменные на уровне модуля, разделяемые фикстуры с побочными эффектами). Любое из этих может утечь между тестами.

Фикстуры page и context в Playwright уже берут на себя изоляцию состояния браузера. Каждый тест автоматически получает свежий BrowserContext: чистую сессию без куки, без localStorage, без ничего перенесённого из других тестов. Не фича которую нужно включать, а поведение по умолчанию. Используешь фикстуру page: изоляция на уровне браузера уже работает.

// Каждый тест получает полностью свежий браузерный контекст. Это автоматически.
test('anonymous user sees login button', async ({ page }) => {
  await page.goto('/dashboard');
  // Нет куки, нет сессии. По-настоящему чисто.
  await expect(page.getByRole('link', { name: 'Log in' })).toBeVisible();
});

test('also anonymous, previous test left no trace', async ({ page }) => {
  await page.goto('/dashboard');
  // Такое же чистое состояние, вне зависимости от того что запускалось раньше
  await expect(page.getByRole('link', { name: 'Log in' })).toBeVisible();
});

Сложная часть: состояние приложения. Playwright не может изолировать твою базу данных. Это твоя работа.

Классические падения изоляции: паттерны которые ты узнаешь

Самое распространённое падение изоляции выглядит так. Тестовый файл содержит тест настройки который создаёт данные, несколько тестов которые эти данные используют, и тест очистки который их удаляет. Кто-то написал так чтобы не повторять код создания.

// tests/user-profile.spec.ts
import { test, expect } from '@playwright/test';

// Вот разделяемое состояние, корень проблемы
let testUserId: number;

test('setup: create test user', async ({ request }) => {
  const response = await request.post('/api/users', {
    data: { name: 'Test User', email: 'testuser@example.com' }
  });
  testUserId = (await response.json()).id;
});

test('can view user profile', async ({ page }) => {
  await page.goto(`/users/${testUserId}`);
  await expect(page.getByRole('heading', { name: 'Test User' })).toBeVisible();
});

test('can edit user name', async ({ page }) => {
  await page.goto(`/users/${testUserId}/edit`);
  // ...
});

test('teardown: delete test user', async ({ request }) => {
  await request.delete(`/api/users/${testUserId}`);
});

При последовательном запуске в порядке файла работает идеально. Ломается четырьмя способами как только условия меняются: включение fullyParallel, удаление тестового пользователя тестом из другого файла по несвязанной причине, падение теста настройки оставляющее testUserId как undefined для всех последующих тестов, или добавление файла в --shard где настройка и очистка попадают на разные машины.

Второе классическое падение: коллизия email-адресов. Тест создаёт пользователя с email: 'alice@test.com'. Тест проходит. При следующем запуске пользователь уже существует потому что очистка предыдущего прогона не сработала (краш браузера, таймаут CI, ошибка теста пропустившая afterAll). Получаешь ошибку 409 Conflict которая выглядит как баг в форме регистрации.

// ПЛОХО: захардкоженный email будет конфликтовать при повторном запуске
test('register new user', async ({ page }) => {
  await page.goto('/register');
  await page.getByLabel('Email').fill('alice@test.com');
  await page.getByLabel('Password').fill('Password123!');
  await page.getByRole('button', { name: 'Create account' }).click();
  await expect(page.getByText('Welcome, alice')).toBeVisible();
});

// ХОРОШО: уникальный email на каждый запуск, коллизии невозможны
test('register new user', async ({ page }) => {
  const email = `alice-${Date.now()}@test.com`;
  await page.goto('/register');
  await page.getByLabel('Email').fill(email);
  await page.getByLabel('Password').fill('Password123!');
  await page.getByRole('button', { name: 'Create account' }).click();
  await expect(page.getByText('Welcome, alice')).toBeVisible();
});

Date.now() простейшая стратегия уникальности. Для более читаемых ID можно добавить случайный суффикс: ` alice-${Date.now()}-${Math.random().toString(36).slice(2, 6)}@test.com . Точный формат не важен пока не будет коллизий.

Изоляция данных: API чтобы владеть миром теста

Правильная модель изоляции: каждый тест через API создаёт всё что ему нужно в начале и удаляет в конце через afterEach или фикстуру очистки. Ни один тест не зависит от того что другой тест что-то создал.

import { test, expect } from '@playwright/test';

test('admin can deactivate a user account', async ({ page, request }) => {
  // Создаём данные которые нужны этому тесту, полностью принадлежащие ему
  const createResponse = await request.post('/api/users', {
    data: {
      name: 'Temporary User',
      email: `temp-${Date.now()}@example.com`,
      role: 'member'
    }
  });
  expect(createResponse.ok()).toBeTruthy();
  const { id: userId } = await createResponse.json();

  try {
    // Сам тест
    await page.goto(`/admin/users/${userId}`);
    await page.getByRole('button', { name: 'Deactivate account' }).click();
    await page.getByRole('button', { name: 'Confirm' }).click();
    await expect(page.getByTestId('account-status')).toHaveText('Inactive');
  } finally {
    // Очистка выполняется даже если тест упал
    await request.delete(`/api/users/${userId}`);
  }
});

Паттерн try/finally здесь критичен. Если поставить очистку в конце теста без finally, при падении теста очистка пропускается и в базе остаются осиротевшие данные. За десятки прогонов эти записи накапливаются и вызывают непредсказуемые падения в других местах.

Более чистый способ в Playwright: кастомная фикстура которая оборачивает жизненный цикл автоматически:

// fixtures/api-fixtures.ts
import { test as base, expect } from '@playwright/test';

type ApiFixtures = {
  createUser: (overrides?: Partial<{ name: string; email: string; role: string }>) => Promise<{ id: number; email: string }>;
};

export const test = base.extend<ApiFixtures>({
  createUser: async ({ request }, use) => {
    const createdIds: number[] = [];

    const factory = async (overrides = {}) => {
      const response = await request.post('/api/users', {
        data: {
          name: 'Test User',
          email: `test-${Date.now()}-${Math.random().toString(36).slice(2, 6)}@example.com`,
          role: 'member',
          ...overrides
        }
      });
      const user = await response.json();
      createdIds.push(user.id);
      return user;
    };

    await use(factory);

    // Очищаем всех пользователей созданных этим тестом, выполняется автоматически после каждого теста
    for (const id of createdIds) {
      await request.delete(`/api/users/${id}`);
    }
  }
});

Теперь любой тест использует createUser и очистка гарантирована:

import { test } from '../fixtures/api-fixtures';
import { expect } from '@playwright/test';

test('editor can update user profile', async ({ page, createUser }) => {
  const user = await createUser({ name: 'Jane', role: 'editor' });

  await page.goto(`/users/${user.id}`);
  await page.getByRole('button', { name: 'Edit profile' }).click();
  await page.getByLabel('Display name').fill('Jane Updated');
  await page.getByRole('button', { name: 'Save' }).click();

  await expect(page.getByRole('heading', { name: 'Jane Updated' })).toBeVisible();
});

Тест читается, очистка невидима и автоматична, а создание нескольких пользователей в одном тесте: просто двойной вызов createUser.

Строй фабрики данных как фикстуры с самого начала. Переделывать их в существующем сьюте значительно сложнее чем начинать с ними. Набор фикстур createUser, createOrder и createProduct покрывает 80% типичных потребностей в тестовых данных для e-commerce.

storageState и изоляция авторизации: один логин, полная изоляция

Паттерн с фикстурой createUser решает изоляцию данных. Аутентификация: отдельная задача. Полный браузерный флоу логина в каждом тесте: медленно. Но и разделять живую браузерную сессию между тестами нельзя: один тест разлогинившийся или изменивший настройки аккаунта сломает все параллельные тесты.

Правильный паттерн: логиниться один раз на воркер (не на тест, не глобально), сохранять аутентифицированный storageState в файл и загружать его в начале каждого теста из этого файла. Каждый тест получает собственный браузерный контекст который стартует аутентифицированным, но контексты не делят никакую живую сессию.

// tests/auth.setup.ts запускается один раз на воркер перед тест-сьютом
import { test as setup, expect } from '@playwright/test';
import path from 'path';

const authFile = path.join(__dirname, '../.auth/user.json');

setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
  await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
  await page.getByRole('button', { name: 'Sign in' }).click();

  // Ждём пока пройдём страницу логина
  await page.waitForURL('/dashboard');
  await expect(page.getByRole('navigation')).toBeVisible();

  // Сохраняем состояние аутентификации
  await page.context().storageState({ path: authFile });
});

// playwright.config.ts
import { defineConfig } from '@playwright/test';
import path from 'path';

export default defineConfig({
  projects: [
    {
      name: 'setup',
      testMatch: '**/auth.setup.ts',
    },
    {
      name: 'authenticated tests',
      dependencies: ['setup'],
      use: {
        storageState: path.join(__dirname, '.auth/user.json'),
      },
      testMatch: '**/*.spec.ts',
    },
  ],
});

С такой настройкой каждый тест стартует аутентифицированным без прохождения флоу логина. Поскольку storageState загружается из файла в новый BrowserContext, сессии полностью изолированы. Что тест A делает со своей сессией не влияет на сессию теста B.

Если в приложении несколько ролей (admin, editor, viewer), создай отдельный файл storageState для каждой роли в ходе настройки. Фикстуры смогут загружать нужное состояние в зависимости от того что требует тест. Это значительно быстрее чем логиниться с разными учётными данными внутри отдельных тестов.

Изоляция делает параллелизм безопасным

Между изоляцией тестов и параллельным выполнением прямая связь. Нельзя безопасно запускать тесты параллельно если они разделяют состояние, и нельзя в полной мере использовать преимущества параллелизма без правильной изоляции. Это две стороны одной монеты.

При параллельном запуске тестов разные воркеры работают одновременно. Порядок между воркерами не гарантирован. Если тест A в воркере 1 создаёт пользователя с email: 'admin@test.com' и тест B в воркере 2 тоже пытается создать пользователя с email: 'admin@test.com', один из них упадёт с нарушением уникальности. Какой? Зависит от гонки по времени. Вот определение флакующего теста.

// playwright.config.ts. Работает только если тесты по-настоящему изолированы.
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,   // Каждый тест в каждом файле выполняется конкурентно
  workers: process.env.CI ? 4 : '50%',
  retries: process.env.CI ? 1 : 0,
  use: {
    baseURL: process.env.BASE_URL || 'http://localhost:3000',
    trace: 'on-first-retry',
  },
});

fullyParallel: true самое ценное изменение конфигурации которое можно сделать в зрелом сьюте. Сьют из 150 тестов по 3 секунды каждый занимает 7,5 минут последовательно. С 4 воркерами и правильной изоляцией это падает примерно до 2 минут. Ограничение не в возможностях Playwright. Ограничение в том изолированы ли твои тесты достаточно чтобы не мешать друг другу.
Не добавляй ретраи чтобы маскировать проблемы изоляции. Ретраи законный инструмент для работы с реальной флакучестью (таймауты сети, сбои сторонних сервисов). Но если тест падает потому что запустился одновременно с другим тестом и они затронули одни данные, ретрай скорее всего пройдёт и ты никогда не узнаешь о проблеме изоляции пока она не разрастётся во что-то хуже. Сначала исправь изоляцию, потом при необходимости добавь ретраи.

Проблемы разделяемого состояния масштабируются с количеством воркеров. Один воркер: тесты случайно запускаются в порядке где проблема состояния не срабатывает. Два воркера: иногда появляются падения. Восемь воркеров: сьют стабильно сломан. Если увеличение воркеров делает сьют более флакующим, это верный сигнал что где-то есть разделяемое состояние.

Поиск утечек изоляции в существующем сьюте

Если достался сьют с подозрением на проблемы изоляции, вот конкретные шаги для их поиска.

Шаг 1: запусти с
--workers=1 и сравни. Если полный сьют проходит с одним воркером и падает с двумя и более, есть проблема изоляции. Тесты которые падают: жертвы. Тесты которые их ломают найти сложнее.

# Проходит ли сьют последовательно?
npx playwright test --workers=1

# Проходит ли он с параллелизмом?
npx playwright test --workers=4

Шаг 2: рандомизируй порядок. Некоторые ошибки изоляции появляются только когда тест A запускается перед тестом B, но они всегда запускаются в одном порядке и ты никогда не видишь падения. В Playwright нет встроенной рандомизации порядка, но можно разделять тесты вручную и запускать их в разных последовательностях чтобы проверить зависимости от порядка. Шаг 3: ищи конкретные паттерны в коде. Переменные на уровне модуля которые тесты записывают: виновник номер один.

// Grep тестовых файлов по этим паттернам. Каждый — потенциальная утечка изоляции.

// Переменная уровня модуля записываемая внутри теста
let userId: number;
let authToken: string;
let createdRecord: any;

// test.beforeAll создающий данные используемые несколькими тестами
test.beforeAll(async ({ request }) => {
  // Если что-то здесь создаёт разделяемое изменяемое состояние — утечка
});

// Захардкоженные email-адреса, имена пользователей, или любые фиксированные уникальные идентификаторы
data: { email: 'fixed@test.com' }
data: { username: 'testadmin' }
data: { id: 1 }

Шаг 4: проверь пути очистки. Найди все
test.afterAll и убедись что каждый вызов очистки также покрыт afterEach или teardown фикстурой. afterAll запускается один раз на сьют. Если тест падает посередине, afterAll всё равно запускается, но очистка может работать с частичным состоянием. Шаг 5: добавь заголовки тестов в записи базы данных. В ходе разработки называй тестовые данные по тесту который их создаёт:

const user = await createUser({
  name: `Test user for: ${test.info().title}`,
  email: `test-${Date.now()}@example.com`
});

Когда смотришь на тестовую базу и видишь десять строк «Test user for: admin can deactivate a user account», сразу понятно что это осиротевшие данные от неудачных очисток именно этого теста.

Как применить это прямо сейчас

Если существующий сьют имеет проблемы изоляции, не пытайся исправить всё сразу. Вот приоритизированный подход который даёт результат немедленно.

Первые 30 минут: аудит переменных уровня модуля в тестовых файлах. Любые
let или var объявленные на уровне модуля которые записываются внутри блока test(): это проблема. Перемести объявления внутрь теста, используй beforeEach для создания свежего состояния, проверь что тесты по-прежнему проходят. Следующий час: замени все захардкоженные уникальные идентификаторы в тестовых данных. Email-адреса, логины, телефоны, любое поле с ограничением уникальности: сделай их динамическими через Date.now() или аналогичный подход. Это устраняет класс падений «тест падает при втором запуске». На этой неделе: построй фикстуру createUser (или для любой другой самой частой сущности). Помести логику создания и удаления в одно место, сделай её автоматической, и перенеси пять наиболее проблемных тестовых файлов на неё. Сразу увидишь насколько проще становятся эти тесты. В этом спринте: включи fullyParallel: true с двумя воркерами и наблюдай за количеством падений. Каждое новое падение: утечка изоляции которая пряталась. Исправляй каждую по мере появления. Когда сьют стабилен на двух воркерах, переходи к четырём. Продолжай пока не упрёшься в лимит машины или сьют не уложится в две минуты.

Цель не идеальная изоляция как абстрактный принцип. Цель: сьют который можно запустить с --workers=8` и доверять результатам. Изоляция механизм. Быстрая надёжная обратная связь: цель. Когда тесты stateless, параллелизм становится просто значением в конфиге.

→ See also: Фикстуры Playwright: от встроенных до кастомных | Отладка нестабильных тестов: практическое руководство | Параллельное выполнение в Playwright: workers, шарды и шардирование для ускорения | Нестабильные тесты: почему они возникают и как их устранить | Управление тестовыми данными в Playwright: стратегии и паттерны