По умолчанию Playwright использует половину логических ядер процессора, поэтому на CI-раннере с 2 ядрами получается 1 воркер и тесты выполняются последовательно. fullyParallel: true запускает каждый тест параллельно независимо от файла, но требует чтобы каждый тест полностью владел своими данными: два теста модифицирующих одну строку БД с захардкоженным ID будут конкурировать и периодически падать. Эта статья разбирает конфигурацию воркеров, требования к изоляции для fullyParallel, test.describe.serial() для намеренно последовательных групп и шардинг через --shard для разбивки большого сьюта по машинам в матрице GitHub Actions.
Как Playwright запускает тесты по умолчанию
Прежде чем менять конфигурацию, стоит понять что Playwright делает из коробки.
По умолчанию Playwright запускает тест-файлы параллельно, а тесты внутри одного файла последовательно. Каждый воркер-процесс берёт тест-файл, запускает все тесты в нём сверху вниз, затем берёт следующий доступный файл. Несколько воркеров работают одновременно, каждый обрабатывает свой файл.
По умолчанию Playwright берёт половину логических ядер процессора. На типичном ноутбуке с 8 ядрами это 4 воркера. На CI-раннере с 2 ядрами получается 1 воркер, то есть тесты выполняются последовательно если не переопределить.
// playwright.config.ts — поведение по умолчанию (ничего менять не нужно)
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
// workers по умолчанию = половина логических ядер
// тесты внутри файла по умолчанию последовательны
});Это разумная отправная точка. Тесты в одном файле часто неявно разделяют состояние настройки (тот же page-объект, тот же флоу входа, те же фикстуры данных), и последовательное выполнение внутри файла защищает это. Разные файлы запускаются одновременно, что даёт ускорение без требования идеальной изоляции между каждым тестом.
Настройка воркеров в playwright.config.ts
Опция workers контролирует количество параллельных процессов. Задаётся как абсолютное число или как процент доступных ядер.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
// Абсолютное число — всегда ровно 4 воркера
workers: 4,
// Или как процент доступных ядер
// workers: '75%',
// Или разные значения в зависимости от окружения
// workers: process.env.CI ? 2 : '50%',
});Процентная форма удобна когда нужен один конфиг для разных машин. '50%' на 8-ядерной машине даёт 4 воркера, на 2-ядерном CI-раннере даёт 1. Говоришь Playwright «используй половину машины» вместо захардкоженного числа.
Воркеры можно переопределить из командной строки не трогая конфиг:
# Запуск с конкретным числом воркеров
npx playwright test --workers=4
# Принудительное последовательное выполнение (1 воркер)
npx playwright test --workers=1--workers=1 полезен для отладки проблем изоляции. Если тесты проходят с 1 воркером но падают с 4, где-то есть общее состояние.
--workers=1. Если тест стабильно проходит, дело в гонке состояний или разделяемом состоянии между тестами, а не в баге самого теста.Режим fullyParallel: всё сразу
Стандартный режим запускает файлы параллельно но тесты внутри файла последовательно. fullyParallel: true снимает это ограничение. Каждый отдельный тест запускается параллельно независимо от файла.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true, // Все тесты параллельно
workers: 4,
});Для больших сьютов это даёт значительное сокращение времени. Сьют из 100 тестов в 10 файлах (2 секунды на тест) падает с 20 секунд до примерно 5 секунд с 4 воркерами в режиме fullyParallel.
Но есть условие: fullyParallel требует чтобы каждый тест был полностью изолирован. Никакого разделяемого браузерного контекста, никакого мутируемого общего состояния входа, никаких тестов зависящих от порядка выполнения. Если тесты пишут в разделяемую запись БД и оба пытаются изменить одну строку одновременно, получишь нестабильные падения которые трудно воспроизвести.
Перед включением fullyParallel проверь тест-сьют на тесты создающие данные с захардкоженными ID (пользователь с ID 123 создаётся тестом A и удаляется тестом B), на тесты которые предполагают что предыдущий тест уже выполнился, и на состояние страницы которое не сбрасывается между тестами.
Если тесты используют test.beforeEach для свежего входа и работают с уникальными данными, fullyParallel безопасно включать. Если разделяют pre-authenticated браузерный контекст хранящийся в модульной переменной, ещё не готовы.
test.describe.serial() для намеренно последовательных тестов
Иногда группа тестов действительно должна выполняться по порядку. Флоу оформления заказа где тест 1 добавляет товар в корзину, тест 2 применяет купон, тест 3 завершает покупку: эти тесты изначально последовательны. test.describe.serial() правильный инструмент для этого.
import { test, expect } from '@playwright/test';
test.describe.serial('checkout flow', () => {
test('add item to cart', async ({ page }) => {
await page.goto('/products/widget-pro');
await page.getByRole('button', { name: 'Add to cart' }).click();
await expect(page.getByTestId('cart-count')).toHaveText('1');
});
test('apply coupon code', async ({ page }) => {
await page.goto('/cart');
await page.getByPlaceholder('Coupon code').fill('SAVE10');
await page.getByRole('button', { name: 'Apply' }).click();
await expect(page.getByTestId('discount-amount')).toBeVisible();
});
test('complete purchase', async ({ page }) => {
await page.goto('/checkout');
await page.getByLabel('Card number').fill('4242424242424242');
await page.getByLabel('Expiry').fill('12/26');
await page.getByLabel('CVC').fill('123');
await page.getByRole('button', { name: 'Pay now' }).click();
await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible();
});
});С test.describe.serial() Playwright запускает эти три теста по порядку и останавливается при первом падении. Нет смысла запускать «complete purchase» если «add item to cart» упал.
Используй serial редко. Каждый serial-блок: участок сьюта который нельзя распараллелить. Если добавляешь serial в большинство блоков describe, реальное решение в другом: сделать тесты независимыми, генерировать уникальные тестовые данные, использовать изолированные браузерные контексты, делать очистку после каждого теста.
Изоляция тестов: предпосылка параллельного выполнения
Параллельное выполнение усиливает проблемы изоляции. Тест который нормально работает в одиночку падает непредсказуемо когда запускается одновременно с другим тестом касающимся тех же данных или состояния.
Ключевой принцип: каждый тест должен владеть своими данными и не зависеть от остатков другого теста.
import { test, expect } from '@playwright/test';
// ПЛОХО: разделяемое состояние между тестами
let userId: number;
test('creates a user', async ({ request }) => {
const response = await request.post('/api/users', {
data: { name: 'Alice', email: 'alice@example.com' }
});
userId = (await response.json()).id; // разделяемая переменная — гонка состояний
});
test('updates the user', async ({ request }) => {
// Если предыдущий тест не выполнился (или в другом воркере), userId не определён
await request.put(`/api/users/${userId}`, {
data: { name: 'Alice Updated' }
});
});import { test, expect } from '@playwright/test';
// ХОРОШО: каждый тест создаёт свои данные и владеет ими
test('updates a user', async ({ request }) => {
// Создаём пользователя внутри теста
const createResponse = await request.post('/api/users', {
data: {
name: 'Alice',
email: `alice-${Date.now()}@example.com` // уникальный email исключает конфликты
}
});
const { id } = await createResponse.json();
// Обновляем — этот пользователь принадлежит нам
const updateResponse = await request.put(`/api/users/${id}`, {
data: { name: 'Alice Updated' }
});
expect(updateResponse.status()).toBe(200);
});Для UI-тестов фикстура page даёт каждому тесту собственный браузерный контекст по умолчанию, это обрабатывается автоматически. Проблемы изоляции обычно приходят из тестовых данных в разделяемой БД, а не из состояния браузера.
test.beforeAll и очищать в test.afterAll кажется эффективным, но создаёт скрытые зависимости между тестами. Если один тест изменит общие данные, последующие тесты сломаются. Предпочитай test.beforeEach с данными на каждый тест, даже если это медленнее.Шардинг: разбивка сьюта по CI-машинам
Воркеры распараллеливают тесты внутри одной машины. Шардинг разбивает тест-сьют по нескольким машинам. Эти два механизма независимы и дополняют друг друга. Можно использовать оба вместе.
Флаг --shard принимает аргумент текущий/всего:
# Запуск шарда 1 из 3 (первая треть тестов)
npx playwright test --shard=1/3
# Запуск шарда 2 из 3
npx playwright test --shard=2/3
# Запуск шарда 3 из 3
npx playwright test --shard=3/3Playwright распределяет тест-файлы равномерно по шардам. При 30 файлах и 3 шардах каждый шард получает 10 файлов. Распределение детерминировано: те же файлы в тех же шардах при каждом запуске.
Шардинг можно комбинировать с воркерами. Каждый шард работает с несколькими воркерами, получая параллелизм и внутри шарда и между шардами:
# Каждый шард использует 4 воркера внутри
npx playwright test --shard=1/3 --workers=4Шардинг полезен прежде всего в CI, где можно выделить несколько машин для одного запуска пайплайна.
Матрица GitHub Actions для параллельного шардинга
GitHub Actions поддерживает матричные сборки: запуск задачи несколько раз с разными входными данными. В сочетании с шардингом Playwright это позволяет разбить медленный тест-сьют по параллельным машинам.
# .github/workflows/playwright.yml
name: Playwright Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run tests (shard ${{ matrix.shard }}/4)
run: npx playwright test --shard=${{ matrix.shard }}/4
env:
BASE_URL: ${{ vars.BASE_URL }}
TEST_USER: ${{ secrets.TEST_USER }}
TEST_PASS: ${{ secrets.TEST_PASS }}
- name: Upload shard report
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report-shard-${{ matrix.shard }}
path: playwright-report/
retention-days: 7fail-fast: false критически важен. По умолчанию если одна матричная задача падает, GitHub отменяет оставшиеся. С fail-fast: false все шарды выполняются до конца даже если один упал. Видишь полную картину что прошло и что упало по всему сьюту.
Аргумент chromium при установке браузера экономит время. Для кросс-браузерных тестов убери аргумент с браузером и используй просто --with-deps чтобы установить все три.
Для слияния отчётов шардов в один добавь задачу merge после завершения матрицы:
merge-reports:
needs: test
runs-on: ubuntu-latest
if: always()
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- name: Download all shard reports
uses: actions/download-artifact@v4
with:
pattern: playwright-report-shard-*
path: all-reports/
merge-multiple: false
- name: Merge reports
run: npx playwright merge-reports --reporter html ./all-reports/*/
- name: Upload merged report
uses: actions/upload-artifact@v4
with:
name: playwright-report-merged
path: playwright-report/
retention-days: 14Получаешь единый скачиваемый HTML-отчёт покрывающий все шарды с результатами всех тестов в одном месте.
Подбор оптимального числа воркеров
Больше воркеров не всегда означает быстрее. Добавление воркеров увеличивает конкуренцию за ресурсы: больше CPU, памяти, браузерных процессов на одной машине. В какой-то момент добавление воркера замедляет всё потому что машина перегружена.
Грубое правило: воркеры = число логических ядер хорошо работает для CPU-нагруженных задач. Браузерные тесты в основном ждут сети и рендеринга, поэтому часто можно поднять выше. Воркеры = 2x ядер: разумный эксперимент.
Как измерить:
# Базовая линия: 1 воркер (последовательно)
npx playwright test --workers=1 2>&1 | grep "passed\|failed\|Duration"
# 2 воркера
npx playwright test --workers=2 2>&1 | grep "passed\|failed\|Duration"
# 4 воркера
npx playwright test --workers=4 2>&1 | grep "passed\|failed\|Duration"
# 8 воркеров
npx playwright test --workers=8 2>&1 | grep "passed\|failed\|Duration"Нарисуй график. Ищешь точку перегиба где добавление воркеров перестаёт сокращать время. Это оптимум для данной машины.
Для CI конкретно: GitHub Actions ubuntu-latest раннеры имеют 4 vCPU и 16 GB RAM. С браузерными тестами Playwright 4 воркера надёжная отправная точка. С большим числом можно получить ещё 5-10% прироста, но при 8+ воркерах на этом раннере начнётся давление на память.
Практическая формула для расчёта выгоды от шардинга:
Время с N шардами ≈ (общее время на 1 машине) / N + фиксированные накладные расходы на шард
Фиксированные накладные = checkout + npm ci + установка браузера ≈ 60-90 секундЕсли сьют занимает 10 минут на 1 машине, 4 шарда дают примерно 2.5 минуты + 90 секунд накладных = ~4 минуты. Ощутимый выигрыш. Если сьют занимает 3 минуты, 4 шарда дают 45 секунд + 90 секунд = 2.5 минуты. Сложность не оправдана.
Порог для шардинга: начинай рассматривать когда сьют стабильно занимает более 5 минут на одной CI-машине.
// playwright.config.ts — продакшн-конфиг для параллельного запуска
import { defineConfig } from '@playwright/test';
const isCI = !!process.env.CI;
export default defineConfig({
testDir: './tests',
// Полный параллелизм — требует изолированных тестов
fullyParallel: true,
// Воркеры в зависимости от окружения
workers: isCI ? 4 : '50%',
// Ретраи только в CI — не скрывай падения локально
retries: isCI ? 1 : 0,
// Таймаут на тест
timeout: 30_000,
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
},
});Этот конфиг документирует компромиссы: полный параллелизм везде, CI получает фиксированные воркеры пока локальная среда получает процент, ретраи только в CI чтобы не скрывать проблемы при разработке.
FAQ
Тесты проходят локально но падают при параллельном запуске. С чего начать?
Запусти с --workers=1 и убедись что тесты проходят. Затем попробуй --workers=2. Если падают, есть проблема разделяемого состояния между двумя тестами которые теперь работают одновременно. Ищи модульные переменные, разделяемые строки БД с захардкоженными ID, любое состояние сохраняющееся между тестами. Решение почти всегда одно: перенести настройку в beforeEach и использовать уникальные идентификаторы для тестовых данных.
Как Playwright решает какие тесты попадают в какой шард?
Playwright сортирует тест-файлы по алфавиту и распределяет их round-robin по шардам. Назначение не контролируется напрямую. Если один шард стабильно занимает значительно больше других (в нём все медленные тесты), разбивай большие тест-файлы на меньшие для более равномерного распределения.
Можно запускать конкретные теги или grep-паттерны на каждом шарде вместо --shard?
Да, и некоторые команды предпочитают это для предсказуемости: --grep @checkout на одной машине и --grep @catalog на другой. Недостаток: ручное обслуживание, нужно обновлять grep-паттерны при добавлении тестов. --shard автоматический и не требует обслуживания.
Влияет ли fullyParallel: true на порядок результатов в отчёте?
Да. С fullyParallel результаты появляются по мере завершения тестов, а не в порядке файлов. HTML-отчёт по-прежнему группирует по файлам и тестам, так что читабельность не страдает. Вывод в терминале просто выглядит более перемежающимся.
В чём разница между workers в конфиге и --shard в командной строке?
workers контролирует параллелизм внутри одного процесса на одной машине. --shard разбивает сьют по нескольким вызовам, обычно на разных машинах. Они работают на разных уровнях и хорошо сочетаются. Каждый шард может иметь несколько воркеров.
→ See also: Отладка нестабильных тестов: практическое руководство | CI/CD для QA: сравнение GitHub Actions, Jenkins и GitLab | GitHub Actions для тестов Playwright: полная настройка (2026) | Изоляция тестов: почему каждый тест Playwright должен быть stateless | Файл конфигурации Playwright: все опции, которые нужно знать