Представьте скромный конфиг: workers: 2. Кому-то показалось, что сборка тянется долго, и число сменили на четыре. Результат неожиданный: зелёная галочка появилась позже, а в отчёте прибавилось повторов. Серверу вроде бы дали больше работы, а он стал справляться хуже. Чтобы понять, почему так бывает, придётся разложить слово «параллельно» на части: что именно удваивается, когда мы удваиваем workers, что при этом не удваивается совсем и как отличить нехватку памяти от нехватки очереди тестов.
Три сущности, которые часто называют одним словом «браузер»
Первая ошибка в расчётах состоит в том, что вкладку, контекст и процесс считают одинаковыми кусочками оперативной памяти. Это разные вещи.
Worker — отдельный процесс операционной системы, и каждый worker запускает собственный браузер. Когда это возможно, worker переиспользуется для следующих файлов с тестами (Playwright: Parallelism). Значит, четыре workers — это четыре независимых процесса-координатора и четыре браузера со своими дочерними процессами, а не один браузер с четырьмя вкладками.
BrowserContext — более лёгкая сущность. Для тестов Playwright создаёт отдельные контексты со своими cookies, local storage и session storage, причём изоляция достигается внутри одного браузера. Контекст не равен самостоятельному процессу браузера, а один тест вправе создать несколько контекстов и страниц (Playwright: Isolation). Поэтому тест с двумя ролями — например, покупатель и администратор — может стоить заметно дороже теста с одной страницей, хотя worker у них один.
Из этого следует практический вывод: формулы вида «один браузер — столько-то мегабайт, умножим на число workers» дают лишь грубую прикидку. Реальный расход зависит от страниц, которые открывают тесты, от количества контекстов в одном сценарии, от веса самого приложения и от того, включена ли запись trace. Одна и та же цифра для лёгкого каталога и для тяжёлой админки с графиками будет заведомо неверной. Отсюда и главный принцип статьи: число нужно измерять на вашем наборе тестов, а не вычислять по чужим таблицам.
Есть и оговорка про изоляцию. Отдельные контексты разделяют cookies и хранилища браузера, но ничего не говорят о серверной базе вашего приложения. Если два параллельных теста правят один и тот же заказ или один аккаунт, контексты их не спасут: разделять такие данные — проектное решение. Это важно для нашей темы, потому что тесты, мешающие друг другу, превращаются в источник повторов, а повторы съедают выигрыш от дополнительных workers.
Один длинный файл и три простаивающих worker
Теперь о том, почему четыре workers не всегда быстрее двух. По умолчанию файлы тестов выполняются параллельно, а тесты внутри одного файла идут последовательно в одном worker. Поэтому рост числа workers сам по себе не распараллеливает один длинный файл (Playwright: Parallelism).
Возьмём условный пример: в проекте шесть файлов. Пять из них занимают по минуте, шестой — checkout на десять минут, потому что в нём сорок сценариев подряд. С двумя workers картина такая: один берёт длинный файл, второй последовательно разбирает пять коротких за пять минут и затем простаивает. Общее время определяется длинным файлом — около десяти минут. Добавим ещё два workers: короткие файлы разойдутся быстрее, но десять минут checkout никуда не денутся. Мы заплатили памятью и процессором за двух новых работников, которым достаются крохи, а на выходе получили тот же хвост.
Чтобы тесты внутри файлов тоже распределялись между workers, нужен параметр fullyParallel: true: он включает параллельное выполнение тестов всего проекта. Для отдельного блока есть test.describe.configure({ mode: 'parallel' }). При этом параллельные тесты не разделяют глобальные переменные, а beforeAll и afterAll выполняются для каждого такого теста отдельно (Playwright: Parallelism). Если в вашем длинном файле первый тест создаёт пользователя, а остальные тридцать девять на него опираются, включение режима сломает сценарий: зависимость придётся убрать, например, подготавливая данные внутри каждого теста.
И всё же не стоит ждать чуда. fullyParallel не гарантирует, что длинный хвост исчезнет: длительность отдельных тестов и общая зависимость от одного экземпляра приложения сохраняются. Если все сорок сценариев упираются в один медленный сервис, ускорение придёт лишь до тех пор, пока он справляется с одновременными запросами.
Практическая проверка проста: посмотрите в отчёте, сколько длится самый долгий файл и сколько всего идёт прогон. Если один файл занимает половину общего времени, дело не в числе workers, а в устройстве набора. Тут помогает переразбить файл или включить параллельность, а не покупать процессоры.
Сравнение одного и двух workers: как не обмануть самого себя
Документация по CI советует начинать с одного worker ради воспроизводимости, а мощным собственным CI допускает параллельный запуск (Playwright: Continuous Integration). Это хорошая отправная точка для опыта: сначала эталон с одним worker, затем один шаг вверх.
Число workers задаётся из командной строки, например npx playwright test --workers=1, или параметром workers в конфигурации (Playwright: Parallelism). Для сравнения важно зафиксировать всё остальное:
- один и тот же commit тестов и приложения;
- одинаковые данные в тестовой среде — перед каждым прогоном возвращайте их в исходное состояние;
- одинаковую настройку trace: режим записи сильно влияет и на время, и на память;
- прогретое окружение: зависимости установлены заранее, чтобы в замер не попадала установка браузеров.
Последовательность подготовки в руководстве по CI — npm ci, затем npx playwright install --with-deps, затем npx playwright test (Playwright: Continuous Integration). Первые две команды в замер не входят, измеряется только третья.
Что считать и чем
Память прогона — это память всей группы процессов: Node.js-координатора, workers, браузеров и их дочерних процессов. Смотреть на один процесс в списке бессмысленно: такая цифра занижает расход в разы. Подходящий инструмент — cgroup v2. Контроллер memory учитывает память группы вместе с потомками, а дочерний процесс при создании наследует cgroup родителя; принадлежность видно в /proc/PID/cgroup, где запись v2 выглядит как 0::/путь (Linux kernel: cgroup v2 memory interface).
В группе есть файл memory.current — текущее потребление группы и потомков — и memory.peak — максимум с момента создания группы либо сброса для конкретного открытого файлового дескриптора. Значения даются в байтах. Прежде чем строить на этом замер, убедитесь, что оба файла существуют в вашем ядре и что memory controller доступен.
Здесь легко ошибиться дважды. Во-первых, если прогон шёл в долгоживущей общей cgroup, например в группе всей службы CI-агента, то её memory.peak отражает максимум за всю историю, а не пик вашего прогона. Для чистого опыта нужна заранее изолированная группа, в которой работают только процессы этого запуска; как её создать, зависит от executor и ваших прав, универсального пути здесь нет. Во-вторых, в современных ядрах запись непустой строки в memory.peak сбрасывает пик лишь для последующих чтений через тот же файловый дескриптор. Пара отдельных команд echo и cat глобального сброса не делает: это две независимые операции с файлом.
Рядом с пиком читайте memory.events: там счётчики high, max, oom и oom_kill. Показатель oom_kill — число процессов группы, убитых OOM killer. Но это накопленные счётчики, поэтому для одного прогона важна разница: запишите значения до старта и после окончания. Если oom_kill вырос, часть браузерных процессов была убита ядром, и тесты, которым не повезло, упали не из-за вашего кода.
Что показывает общесистемное наблюдение
Пик группы отвечает на вопрос «сколько съели», но не на вопрос «как себя чувствовала машина». Для этого годится vmstat с ограниченной серией, например vmstat 1 60: задержка и число повторов, после которых вывод заканчивается. Без числа повторов серия идёт бесконечно. Параметр -y убирает первый отчёт, где показаны средние значения с момента загрузки (procps-ng: vmstat(8)).
Читать вывод нужно аккуратно:
swpd— объём занятого swap. Это не показатель текущего дефицита RAM и не пик потребления: часть страниц могла уйти в подкачку давно и просто лежать там. Занятый swap сам по себе не доказывает, что физическая память кончилась.siиso— объём подкачки с диска и на диск за секунду. Вот это уже признак движения: если во время прогона столбцы регулярно ненулевые, система активно перекладывает страницы, и тесты замедляются.r— число готовых к исполнению процессов (работающих или ждущих CPU);b— процессов, ожидающих ввода-вывода. Это наблюдения, а не готовый диагноз «не хватает столько-то ядер».
Так получаются три независимых свидетельства: пик памяти группы, приращения счётчиков memory.events и динамика si/so с очередью r. Но низкий пик относительно RAM, неизменный oom_kill и нулевые si/so ещё не доказывают, что память не тормозила прогон. Если у группы задан memory.high, при его превышении процессы замедляются и выполняют direct reclaim, хотя OOM killer не вызывается и swap может не использоваться (Linux kernel: cgroup v2). Поэтому сначала посмотрите, какой порог memory.high установлен, и сравните значение high в memory.events до и после прогона: если счётчик вырос, часть времени ушла на давление памяти, даже когда пик далёк от общего объёма RAM. Только если порога нет или high не изменился, а oom_kill и si/so тоже спокойны, имеет смысл искать причину в CPU или в наборе тестов. Если при этом r заметно превышает число vCPU на протяжении всего прогона, процессы стоят в очереди за процессором, и больше workers лишь удлинят эту очередь; однако такой признак говорит о нехватке CPU и не снимает вопрос о задержках памяти, пока не проверен счётчик high.
Как читать пару прогонов
Допустим (условные числа, чтобы показать логику), на сервере с 4 ГБ RAM прогон с одним worker занял 12 минут при пике памяти группы 1,1 ГБ, а с двумя — 7 минут при пике 1,9 ГБ. Прирост памяти не вдвое, потому что часть расхода — общая: Node.js-координатор и системные накладные одни на всех. Ускорение тоже не вдвое: хвост длинных файлов и общее приложение не ускоряются. Остаётся запас примерно 2 ГБ, oom_kill не вырос, si/so нулевые. Значит, можно пробовать три workers. Если же на третьем r уходит вверх, а время почти не падает, — остановитесь на двух. Решение определяют данные следующего шага, а не заранее выбранное «красивое» число.
Ещё одна деталь: в замеры лучше включать и дополнительные эффекты вроде trace. Режим trace: 'on' записывает каждый тест и отмечен в документации как затратный по производительности; retain-on-failure записывает traces, а затем удаляет успешные (Playwright: Trace viewer). Если вы сравниваете один и два workers, а в одном прогоне trace включён, а в другом нет, разница будет не про параллельность.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.
Цена каждого упавшего теста
Вернёмся к началу: четыре workers дали больше повторов. Чтобы понять связь, нужно знать, что происходит после падения. Playwright отбрасывает весь worker вместе с браузером и запускает новый; beforeAll нового worker выполняется заново. При включённых retries новый worker начинает с повтора упавшего теста, а по умолчанию повторы отключены (Playwright: Retries).
Обратите внимание на масштаб события: падение — это не только повторный запуск одного теста. Это завершение процесса, запуск нового браузера, повторная подготовка в beforeAll и затем сам повтор. Если у вас тяжёлая подготовка — логин, создание данных, прогрев приложения, — каждое падение превращается в заметную паузу. А если падений много, потому что при четырёх workers приложение и машина не успевают отвечать, система попадает в петлю: перегрузка вызывает таймауты, таймауты вызывают пересоздание workers, пересоздание добавляет нагрузки в тот же момент.
Есть и вторая сторона — как читать результат. В отчёте passed означает успешный первый запуск; flaky — падение с последующим успешным повтором; failed — падение после всех повторов. Успех после retry не равен прохождению с первой попытки (Playwright: Retries). Поэтому при сравнении конфигураций смотрите не только на итоговую зелёную галочку, но и на счётчик flaky. Конфигурация, которая закончилась за шесть минут с двенадцатью flaky, не обязательно лучше той, что заняла восемь минут без единого повтора: первая просто прячет нестабильность за повторами и тратит время на перезапуски, которые видны только в деталях.
Отсюда сравнительная таблица, в которой полезно вести учёт каждой попытки (числа — шаблон для ваших замеров):
| Параметр | 1 worker | 2 workers | 3–4 workers |
|---|---|---|---|
| Общая длительность | замерить | замерить | замерить |
| Пик memory.peak группы | замерить | замерить | замерить |
| Прирост oom_kill | замерить | замерить | замерить |
| Ненулевые si/so в vmstat | да/нет | да/нет | да/нет |
| passed / flaky / failed | замерить | замерить | замерить |
Как записывать диагностику без лишней нагрузки
Для CI документация показывает сочетание retries: 1 и use: { trace: 'on-first-retry' }: trace.zip формируется при первом повторе упавшего теста (Playwright: Trace viewer). Для измерений это удобно: успешные тесты не платят за запись, а упавшие оставляют материал для разбора. Сохранённый trace открывается локально командой npx playwright show-trace path/to/trace.zip, а HTML-отчёт — командой npx playwright show-report. Режим --trace on оставьте для отдельной локальной диагностики.
Один нюанс этого режима: при retries: 1 в конфигурации вы допускаете flaky-результаты. Поэтому честная оценка ёмкости сервера включает проверку, что число flaky не растёт вместе с числом workers. Если растёт, значит, дополнительные браузеры мешают, и речь не о красоте графиков, а о перегрузке.
Зависший прогон не должен занимать сервер до утра
Измерения на VPS имеют неприятный побочный эффект: однажды один из прогонов повиснет. Для этого в конфигурации есть global timeout — Playwright сам завершает зависший прогон и успевает подготовить отчёт. Значение globalTimeout в playwright.config.ts задаётся в миллисекундах. Внешний таймаут CI должен оставаться заметно больше, чтобы Playwright завершился первым (Playwright: Continuous Integration). Если сначала сработает внешний механизм, процесс просто оборвётся, и отчёта, из которого вы понимаете, где именно застряли, не будет. Для эксперимента с числом workers это особенно важно: зависание на трёх workers без отчёта легко принять за нехватку ресурсов.
Когда увеличивать машину, а когда делить набор на shards
Допустим, замеры показали: на двух workers пик памяти близок к границе RAM, а oom_kill растёт. Тогда естественный ход — взять сервер побольше. У MAATRIX на VPS указаны NVMe, AMD EPYC и root-доступ; среди тарифов есть Uab (2 vCPU, 4 ГБ RAM, 80 ГБ NVMe) и Heka (4 vCPU, 8 ГБ, 160 ГБ). Это ресурсы, а не гарантия числа браузеров, поэтому после смены тарифа тот же опыт нужно повторить: пик, события памяти, si/so и flaky. Больше ядер и памяти даёт пространство для опыта, но не отменяет длинного файла в наборе.
Второй путь — shards. Команда npx playwright test --shard=1/2 выполняет первую из двух частей, а --shard=2/2 — вторую; shard может работать как отдельный CI job на отдельной машине (Playwright: Sharding). То есть вместо одного большого сервера можно использовать два меньших, и память каждого будет рассчитываться по своей половине набора.
Но здесь тот же подводный камень, что и с workers. Без fullyParallel распределение идёт в основном по файлам, и длинные файлы дают неравномерную нагрузку. С fullyParallel отдельные тесты распределяются, что позволяет выровнять их количество между shards (Playwright: Sharding). Выровненное количество тестов — не то же самое, что равная длительность. Если в одном shard оказались десять тяжёлых сценариев checkout, а в другом десять быстрых проверок заголовков, второй закончит рано и будет ждать. Поэтому ускорение вдвое от двух shards обещать нельзя; измеряйте время каждого shard отдельно и ориентируйтесь на самый медленный.
Сравнение двух стратегий удобно держать в голове так:
- Больше workers на одном сервере — сильнее конкуренция за память и CPU внутри одной машины, но проще настройка и один отчёт. Хорошо, пока пик памяти и очередь
rспокойны. - Shards на нескольких серверах — каждая машина считается отдельно, нагрузка делится на физически разные ресурсы, но нужно объединять результаты и следить за разбросом длительностей. Хорошо, когда один сервер упёрся в память, а набор достаточно большой, чтобы делиться без больших перекосов.
Если вы тестируете реальный магазин, где сценарии включают корзину и тестовый заказ, посмотрите разбор проверки интернет-магазина через Playwright: там понятно, откуда берутся длинные сценарии, которые потом определяют хвост прогона.
Один параметр вместо четырёх
Вернёмся к исходной истории с четырьмя workers. Разумнее всего повторить её как опыт: вернуть workers: 2, зафиксировать commit, данные и trace, снять пик группы, приращение oom_kill, si/so и счётчик flaky. Затем поменять ровно одно число и сравнить. Если время упало, повторов не прибавилось, а память держится с запасом, оставляйте новую настройку. Если галочка пришла позже, а flaky вырос, четыре процесса мешали друг другу или приложению, и обратная настройка быстрее даёт надёжный ответ. Лучшая конфигурация — не та, где работает больше браузеров, а та, где за разумное время вы получаете результат, которому можно верить.