Skip to content

06 · Parallel Execution & Sharding

Why parallelize

A suite of 500 E2E tests at ~2 seconds each is nearly 17 minutes run serially. Since most Playwright tests spend most of their wall-clock time waiting on the browser/network rather than the CPU, running many at once (across processes, and often across multiple CI machines) cuts wall-clock time dramatically without needing more powerful hardware per-test.

pytest-xdist for local/CI parallelism

pip install pytest-xdist
pytest -n auto
gw0 [500] / gw1 [500] / gw2 [500] / gw3 [500]
................................................ [100%]
500 passed in 187.32s (0:03:07)

-n auto spawns one worker process per CPU core; -n 4 pins it to a specific count. Each worker gets its own Python interpreter and, with pytest-playwright's default fixtures, its own browser/context per test — so tests running concurrently never share mutable state unless you've explicitly built a fixture that does (a shared file, a shared test-account login), which is the most common cause of "passes alone, fails with -n auto."

Test isolation requirements for safe parallelism

# BAD: all workers hit the same test account and could race
def test_updates_profile_name(page):
    login_as(page, "shared_test_user@example.com")
    ...

# GOOD: each test gets a fresh, uniquely-named account or resource
import uuid

def test_updates_profile_name(page):
    email = f"test_{uuid.uuid4().hex[:8]}@example.com"
    register_new_account(page, email)
    ...
# no output — the difference only shows up as flaky, hard-to-
# reproduce failures under -n auto, since two workers editing the
# same shared_test_user's profile concurrently silently clobber
# each other's changes

The rule: any two tests must be safe to run at the exact same instant, against the same backend, without affecting each other's outcome. Shared read-only fixtures (a public catalog page) are fine; shared mutable state (one login used everywhere, one row in a database everyone edits) is the top cause of "flaky under parallel, fine alone."

Built-in parallelism via playwright.config (JS) vs. pytest workers (Python)

Playwright's JS/TS test runner parallelizes natively with a workers config option; the Python pytest-playwright plugin instead delegates parallelism entirely to pytest-xdist, since that's the general-purpose Python test parallelism tool the ecosystem already uses. Functionally the outcome is equivalent — N worker processes running the suite concurrently — but Python projects reach for -n auto rather than a Playwright-specific setting.

# pytest.ini
[pytest]
addopts = -n auto --dist loadscope
# no output — addopts bakes the flag into every `pytest` invocation
# so CI and local runs stay consistent without retyping flags

--dist loadscope groups tests by module (or class) onto the same worker, useful when a conftest.py fixture with scope="module" does expensive one-time setup you don't want repeated per-test across workers.

Sharding across multiple CI machines

Parallelism inside one machine has a ceiling (CPU cores, memory for N browser instances). Sharding splits the whole suite across separate CI job runners, each handling a fraction of the tests.

# .github/workflows/e2e.yml (excerpt)
strategy:
  matrix:
    shard: [1, 2, 3, 4]
steps:
  - run: pytest --shard-id=${{ matrix.shard }} --num-shards=4
# conftest.py — a minimal manual sharding implementation
def pytest_addoption(parser):
    parser.addoption("--shard-id", type=int, default=1)
    parser.addoption("--num-shards", type=int, default=1)

def pytest_collection_modifyitems(config, items):
    shard_id = config.getoption("--shard-id")
    num_shards = config.getoption("--num-shards")
    items[:] = [
        item for i, item in enumerate(items)
        if i % num_shards == (shard_id - 1)
    ]
# no output — pytest_collection_modifyitems runs after test
# collection but before execution, so each of the 4 CI jobs only
# executes roughly a quarter of the total collected tests

In practice, pytest-split is the standard plugin for this (it also supports duration-based balancing so shards finish around the same time, rather than a naive modulo split where one shard happens to get all the slow tests) — the hand-rolled version above shows the mechanism plainly.

pip install pytest-split
pytest --splits 4 --group 1     # on CI runner 1
pytest --splits 4 --group 2     # on CI runner 2
Running group 1/4 (127 tests)
...

Combining both

A common CI layout: 4 shards (matrix jobs), each shard itself running with -n auto internally — sharding gives you horizontal scale across machines, -n auto gives you vertical scale within each machine.

pytest --splits 4 --group ${{ matrix.shard }} -n auto
# no output — flags compose: pytest-split selects this shard's
# subset of tests first, then pytest-xdist parallelizes that subset

How It Actually Works

Each pytest-xdist worker (gw0, gw1, ...) is a fully separate Python interpreter process, not a thread — this matters because it's also a separate Playwright driver connection: each worker's browser fixture opens its own Node driver subprocess and its own launched browser process with its own CDP WebSocket, entirely independent of every other worker. That process-level isolation is exactly what makes parallel Playwright safe at the protocol layer — there's no shared CDP connection two workers could race on, so any flakiness under -n auto has to come from the application under test (a shared database row, a shared login) rather than from Playwright's own automation channel colliding with itself.

Within one worker, session-scoped browser plus function-scoped context is what keeps N tests in that worker fast: reusing one already- launched browser process for all of that worker's tests means only Target.createBrowserContext/Target.disposeBrowserContext CDP round trips happen between tests, not a full Browser.launch and OS process spawn each time — the latter is the dominant cost pytest-xdist's process-per-worker model deliberately avoids paying per test.

Exercise

  1. Take an existing small suite (5-10 tests) and time a serial run with pytest alone, then with pytest -n auto; record both durations.
  2. Deliberately introduce a shared-state bug: two tests that both log into the same hardcoded account and mutate a profile field with opposing values. Run with -n 2 several times and observe intermittent failures that don't happen with -n 1.
  3. Fix it using a uuid-suffixed unique account/resource per test, and confirm -n 2 is now consistently green across several runs.
  4. Install pytest-split, split your suite into 3 groups, and run each group's command separately, confirming the union of all three groups' test counts equals your total test count with none run twice.