lenec ru

← все посты

Параллельный запуск Playwright в CI без race-conditions

13K

Параллельный запуск тестов — естественный шаг, когда набор переваливает за полсотни кейсов. Один воркер прогоняет час, четыре воркера — пятнадцать минут. 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. На сотне тестов он становится бутылочным горлышком, а сценарии перепутываются.

Чек-лист

  1. Включить fullyParallel: true.
  2. Завести фикстуру свежего пользователя на каждый тест.
  3. Прогнать набор 5 раз в случайном порядке — проверить, что гонок нет.
  4. На дорогих сетапах — фикстура с scope: 'worker'.
  5. Договориться с бекендом о служебных эндпоинтах.
  6. Внешние ресурсы с лимитом — через семафорные фикстуры.
  7. Большой набор — шардинг между несколькими CI-раннерами.

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

Комментарии 1

  • Дмитрий Орлов

    Гонял Playwright в self-hosted runner на k8s, и race-conditions ловили на shared Postgres-фикстурах: транзакция теста A коммитилась, тест B видел её записи. Перешли на schema-per-worker через pgnamespace, и тесты стали изолированными. Тоже вариант, если у тебя под каждый workerIndex своя миграция отрабатывает за пару секунд. Как ты решаешь это для интеграционных тестов с общей БД?

Войдите, чтобы оставить комментарий.