Параллельный запуск Playwright в CI без race-conditions
Параллельный запуск тестов — естественный шаг, когда набор переваливает за полсотни кейсов. Один воркер прогоняет час, четыре воркера — пятнадцать минут. Playwright из коробки умеет распараллеливаться и по файлам, и по тестам внутри файла. Включаешь workers: 4 в конфиге — получаешь быстрый прогон. Потом начинаешь получать странные падения на CI, которые не воспроизводятся локально.
Главная причина — гонки на тестовых данных. Тесты делят БД, очередь, файловое хранилище, и порядок их выполнения становится недетерминированным. Расскажу, что попробовала за пять лет работы с параллельными прогонами, что взлетело, что не зашло.
Базовые настройки
// playwright.config.ts
export default defineConfig({
fullyParallel: true,
workers: process.env.CI ? 4 : undefined,
forbidOnly: !!process.env.CI,
});
fullyParallel: true разрешает Playwright параллелить тесты внутри одного файла, не только между файлами. Это даёт ещё буст по скорости, но усиливает все проблемы с данными. Включаю всегда — без него тесты всё равно нужно делать независимыми.
Главное правило: каждый тест — свои данные
Если два теста читают одного и того же пользователя, дёргают одну и ту же корзину, отправляют сообщение в общий чат, рано или поздно они упадут. Не «может быть упадут» — упадут.
Проверка простая: запусти тесты в случайном порядке и в три параллельных воркера несколько раз подряд. Если есть гонки — они вылезут.
for i in 1 2 3 4 5; do
npx playwright test --workers=4 --reporter=list
done
Если из пяти прогонов хотя бы один даёт другие падения — у тебя гонка. Не флакай-Playwright, не «сеть тормозит», а гонка между тестами.
Как изолировать данные
Использую три уровня. На каждом проекте подбираю по сложности и стоимости.
Уровень 1: уникальные данные на каждый тест
Самый простой и работающий способ. На входе теста создаётся пользователь с уникальным email, на выходе — удаляется. Всё, что тест трогает в этой сессии, привязано к этому пользователю.
import { test as base } from '@playwright/test';
import { randomUUID } from 'node:crypto';
export const test = base.extend<{ user: { email: string; password: string } }>({
user: async ({ request }, use) => {
const email = `qa+${randomUUID()}@example.com`;
const password = 'Hunter2!Hunter2';
await request.post('/api/test/users', { data: { email, password } });
await use({ email, password });
await request.delete(`/api/test/users/${encodeURIComponent(email)}`);
},
});
Подходит для большинства сценариев в e2e. Параллельный запуск с десятью воркерами создаёт десять независимых пользователей и не пересекается.
Уровень 2: уникальный неймспейс на воркер
Если создание пользователя дорогое (например, миграция данных, привязка к стороннему сервису), переходим на схему «один тестовый пользователь на воркер». Каждому воркеру в Playwright выдаётся уникальный workerInfo.workerIndex:
import { test as base } from '@playwright/test';
export const test = base.extend<{}, { workerUser: { email: string } }>({
workerUser: [
async ({}, use, workerInfo) => {
const email = `qa-worker-${workerInfo.workerIndex}@example.com`;
// создаётся один раз на воркер, переиспользуется тестами этого воркера
await use({ email });
},
{ scope: 'worker' },
],
});
Внутри одного воркера тесты идут последовательно, гонок нет. Между воркерами — пользователи разные, тоже нет. Минус — после теста состояние пользователя надо чистить, иначе следующий тест в том же воркере увидит остатки.
Уровень 3: отдельная БД на воркер
На самых тяжёлых интеграционных тестах поднимаю БД-в-Docker отдельную для каждого воркера. Конфигурация:
// globalSetup.ts
import { execSync } from 'node:child_process';
async function globalSetup(config: import('@playwright/test').FullConfig) {
const workers = config.workers ?? 1;
for (let i = 0; i < workers; i++) {
execSync(`docker compose -p qa_${i} up -d --wait`, { stdio: 'inherit' });
}
}
export default globalSetup;
В тесте URL базы берётся из переменной с подстановкой workerIndex. Дорого по ресурсам, но решает все возможные гонки на стейте. Включаю на проектах, где БД сильно завязана на бизнес-логику и обычные неймспейсы не выручают.
Тестовый бекенд: служебные эндпоинты
Без поддержки со стороны бекенда параллельный e2e страдает. Прошу разработчиков добавить набор служебных эндпоинтов, доступных только в test-окружении:
POST /api/test/users— создать пользователя.DELETE /api/test/users/{email}— удалить пользователя со всем его контентом.POST /api/test/reset— сбросить состояние конкретного аккаунта.POST /api/test/clock— установить «текущее время» для конкретного пользователя.
Защищать эти эндпоинты заголовком X-Test-Token, который выдаётся только тестовому контуру. На проде они выключаются на уровне фичефлага, чтобы случайно не выкатились.
Без этих эндпоинтов параллельный e2e деградирует в долгий сетап через UI. Один тест регистрируется, заполняет анкету, привязывает карту — две минуты только подготовка. На сотне тестов это полчаса лишнего времени.
Гонки за внешними ресурсами
Проверка платежей через песочницу провайдера, отправка SMS через тестовый шлюз, подключение к стороннему API — все эти ресурсы имеют свои лимиты. Десять параллельных тестов могут упереться в rate limit.
Для таких мест я выделяю фикстуру с семафором:
import { test as base } from '@playwright/test';
let payTokens = 3; // не больше трёх параллельных платёжных тестов
const queue: Array<() => void> = [];
async function acquire(): Promise<void> {
if (payTokens > 0) {
payTokens--;
return;
}
await new Promise<void>((resolve) => queue.push(resolve));
}
function release(): void {
payTokens++;
const next = queue.shift();
if (next) {
payTokens--;
next();
}
}
export const test = base.extend<{ paySlot: void }>({
paySlot: async ({}, use) => {
await acquire();
try {
await use();
} finally {
release();
}
},
});
Внутри теста, использующего paySlot, гарантированно идёт не больше трёх параллельных. На остальные тесты лимит не влияет.
Распараллеливание между машинами
Один CI-раннер с 4 воркерами хорош до 100 тестов. Дальше начинаются физические ограничения: CPU, память, время загрузки браузера. Playwright поддерживает шардинг прогона между несколькими машинами:
jobs:
e2e:
strategy:
matrix:
shard: [1, 2, 3, 4]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npx playwright test --shard=${{ matrix.shard }}/4
- uses: actions/upload-artifact@v4
if: always()
with:
name: blob-${{ matrix.shard }}
path: blob-report
После — отдельная джоба сливает blob-репорты в один HTML-отчёт через npx playwright merge-reports. На прогоне в 500 тестов это сокращает время с 25 до 7 минут.
Что не зашло
- Сериализация всех тестов через
test.describe.serial. Это лекарство хуже болезни: набор работает в один поток, теряется весь смысл параллели. - Сидинг общей БД перед всем прогоном. Тесты дерут общую тарелку — гонки, гонки, гонки.
- «Чистим базу после каждого теста через DROP/CREATE». Дорого, медленно, и параллельные тесты ломают друг друга прямо во время чистки.
- Один общий тестовый аккаунт с моком SMS. На сотне тестов он становится бутылочным горлышком, а сценарии перепутываются.
Чек-лист
- Включить
fullyParallel: true. - Завести фикстуру свежего пользователя на каждый тест.
- Прогнать набор 5 раз в случайном порядке — проверить, что гонок нет.
- На дорогих сетапах — фикстура с
scope: 'worker'. - Договориться с бекендом о служебных эндпоинтах.
- Внешние ресурсы с лимитом — через семафорные фикстуры.
- Большой набор — шардинг между несколькими CI-раннерами.
Параллельный запуск работает не потому, что в конфиге стоит workers: 4. Он работает, когда тесты по-настоящему независимы, а это вопрос дизайна тестов и поддержки от бекенда. Без этой пары любой выигрыш по скорости перекрывается часами разбора flaky.