По умолчанию 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/3

Playwright распределяет тест-файлы равномерно по шардам. При 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: 7

fail-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: все опции, которые нужно знать