CI/CD Integration
A Playwright suite that passes on a laptop is only half-finished; the suite that matters runs unattended on every pull request, on clean machines, with no display attached, and returns a verdict fast enough that nobody is tempted to bypass it. Continuous integration changes the constraints. Browsers must be installed and cached so each job does not re-download hundreds of megabytes. Execution must be headless. The wall-clock time of the slowest job — not the total CPU time — gates the merge, so the work has to spread across workers within a machine and shards across machines. And when something fails, the pipeline must hand back a trace, a screenshot, and a video, because nobody can attach a debugger to a job that already exited. This guide covers how to take a green local suite and make it a reliable, fast, observable gate in any CI system, building on Playwright Setup & Core Architecture.
Why a locally green suite breaks in a pipeline
Most CI failures on a first migration are not test bugs at all. They are the suite discovering that six unstated assumptions baked into the developer machine no longer hold. A laptop has a window server, a warm browser cache, eight idle cores, a stable network, a human watching the run, and a filesystem that survives between invocations. A CI runner has none of those. Every one of the techniques in this guide exists to replace an assumption the runner took away.
The most expensive of those assumptions is timing. A test that passes locally in 400 milliseconds because the app was already warm and the CPU was idle may take four seconds on a shared two-core runner competing with three other worker processes. If the test leans on an implicit assumption about speed — a fixed sleep, an assertion that fires before a request settles, an element queried before hydration — the runner turns that latent bug into an intermittent failure. This is why CI seems to "create" flakiness: it does not create it, it exposes it by changing the timing distribution. The durable fix is deterministic synchronization rather than longer timeouts, which is the subject of Waiting for Network Idle vs Element State.
The second most expensive assumption is state. Locally you log in once and stay logged in for a week; in CI every job starts from an empty profile, so authentication, seeded fixtures, and browser storage all have to be produced from scratch inside the job. The third is observability: locally you saw the failure happen, in CI the process exited and took the evidence with it unless you configured the run to write it to disk and the job to upload it.
Prerequisites
You need a suite that already runs green locally with npx playwright test, a config file that the runner can find at the repository root, and a way to bring the application under test up inside the job — a dev server command, a container, or a deployed preview URL. You also need somewhere to store artifacts: every mainstream provider offers a job-scoped artifact store with a retention window, and Playwright's traces and videos are designed to be dropped straight into it. Finally, decide up front which browsers gate the merge. Running all three engines on every commit triples install time and runner cost for a signal most teams only need nightly; see Cross-Browser Execution for how to split that decision between a fast on-commit project and a broader scheduled one.
Headless execution is the CI default
Playwright runs headless unless told otherwise, which is exactly what a CI runner needs because there is no display server attached. The first thing that breaks when teams move from a laptop to a pipeline is missing operating-system libraries that the browser binaries depend on — fonts, graphics, and audio shared objects that a desktop already has but a minimal CI image does not. Install both the browsers and their OS dependencies in one command so the runner is provisioned correctly; npx playwright install --with-deps chromium does both and is idempotent, so it is safe to run after a cache restore.
import { defineConfig, devices } from '@playwright/test';
// One config, two behaviors: locals get headed-friendly defaults,
// CI gets the strict settings a pipeline needs.
export default defineConfig({
// Fail the build if a test was accidentally left with test.only.
forbidOnly: !!process.env.CI,
// Retry only in CI, where transient infra noise is real.
retries: process.env.CI ? 2 : 0,
// Pin worker count in CI for predictable timing; auto-detect locally.
workers: process.env.CI ? 4 : undefined,
// Hard ceiling for the whole run so a hung job fails fast instead of
// burning the provider's 6-hour default before anyone notices.
globalTimeout: process.env.CI ? 30 * 60_000 : undefined,
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
// Keep a trace whenever the first attempt fails — the forensic record.
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});
The CI environment variable is set automatically by virtually every provider, so a single config branches cleanly between local and pipeline behavior. The forbidOnly flag is a small but high-value guard: it turns an accidentally committed test.only into a failed build instead of a suite that silently runs one test. globalTimeout is the companion guard at the other end — without it, a job that deadlocks on a never-resolving navigation sits in the queue until the provider's own limit expires. These settings extend the patterns in Playwright Config & Fixtures, where the base configuration object and fixture lifecycle are defined.
One headless-specific detail catches teams out: the default viewport in a headless run is 1280x720, and any test whose layout assertions depend on a wider window will see a different rendered tree than the maximized browser on the laptop did. Set the viewport explicitly in use rather than inheriting whatever the device preset supplies, so the CI rendering is reproducible and screenshot baselines stay portable. That portability question is itself a discipline, covered in Managing Screenshot Baselines Across Platforms.
Caching browser binaries
Downloading Chromium, Firefox, and WebKit on every run wastes minutes and bandwidth — a full three-engine install is several hundred megabytes and routinely takes longer than the tests themselves. Playwright stores binaries in a versioned cache directory keyed by the browser revision it ships with, so the cache key must include the resolved Playwright version. Key it on the package name alone and a version bump silently reuses stale browsers, which surfaces as a cryptic "Executable doesn't exist" error the first time the runner asks for a revision that is not in the restored directory.
Compute the key from the installed package rather than from a hand-maintained string, so the key changes exactly when the binaries do.
import { createRequire } from 'node:module';
import { appendFileSync } from 'node:fs';
// Resolve the version the lockfile actually installed, not the semver range
// in package.json — a caret range would produce an unstable key.
const require = createRequire(import.meta.url);
const { version } = require('@playwright/test/package.json') as { version: string };
// Platform and arch belong in the key: browser builds are not portable
// between a Linux x64 runner and an arm64 one.
const key = `pw-browsers-${process.platform}-${process.arch}-${version}`;
// Hand the key to the CI step that restores and saves the cache.
appendFileSync(process.env.GITHUB_OUTPUT ?? '/dev/stdout', `cache-key=${key}\n`);
When the cache hits, npx playwright install becomes a fast verification pass; when it misses, the install repopulates the directory for the next run. Two refinements matter at scale. First, cache the OS dependency installation separately or bake it into an image — --with-deps runs a package manager and is not covered by the browser cache. Second, restrict the cache to the engines you actually run; caching WebKit for a suite that only gates on Chromium inflates both the upload and the restore. The full mechanics, including per-provider cache paths, partial-restore keys, and how to prove a hit rather than assume one, are in Caching Playwright Browsers in CI.
Parallel workers within a machine
Inside a single job, Playwright runs test files in parallel across worker processes. Each worker is an isolated process with its own browser instance, so tests in different files cannot share state by accident — the same isolation model described in Browser Contexts & Isolation, lifted one level up to the process. The right worker count is bounded by the runner's CPU and memory: oversubscribing causes the browsers to contend for cores, which inflates per-test latency and manufactures timeouts that look like flakiness. A typical two-core CI runner is healthy at two to four workers. Pin the number with workers in the config rather than letting auto-detection guess on a runner whose reported core count may be misleading — containerized runners frequently report the host's core count while being cgroup-limited to a fraction of it.
Memory is the constraint people forget. A Chromium instance with a handful of tabs comfortably occupies several hundred megabytes, so eight workers on a runner with 7 GB of RAM will start swapping, and a swapping runner produces failures that look exactly like application slowness. If you see timeouts that move around the suite between runs rather than sticking to specific tests, suspect worker over-subscription before suspecting the tests.
import { test, expect } from '@playwright/test';
// Mark a single file as serial when its tests genuinely depend on order,
// so the parallel default does not corrupt shared, sequential state.
test.describe.configure({ mode: 'serial' });
test('step one seeds the account', async ({ page }) => {
await page.goto('/onboarding');
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});
test('step two builds on the seeded account', async ({ page }) => {
await page.goto('/dashboard');
await expect(page.getByRole('row')).toHaveCount(2);
});
Reserve serial mode for the rare file whose tests are inherently ordered; everything else should stay independent so parallelism stays free. Note the cost of serial mode in CI: if any test in a serial file fails, the remainder are skipped, so one broken step hides the state of everything after it. When the ordering exists only because setup is expensive rather than because the tests are genuinely sequential, move the setup into a worker-scoped fixture instead — Worker-Scoped Fixtures for Expensive Setup shows how to pay that cost once per process while keeping the tests parallel.
Sharding across machines
Workers parallelize within one runner, but the slowest single runner still gates the merge. Sharding splits the entire test set across several machines that run at the same time, each executing a slice with --shard=index/total. Three shards of four workers each gives you twelve-way concurrency, and the gate finishes in roughly a third of the single-machine wall-clock time. The catch is that each shard produces its own partial result, so a naive setup yields three separate reports and no single verdict. The fix is the blob reporter: each shard writes a machine-readable blob, and a final job downloads every blob and runs merge-reports to produce one HTML report and one exit code.
import { defineConfig } from '@playwright/test';
export default defineConfig({
// Blob is the shard-safe reporter: it emits a mergeable artifact rather
// than a self-contained HTML report that would overwrite its siblings.
reporter: process.env.CI
? [['blob'], ['github'], ['list', { printSteps: false }]]
: [['html', { open: 'never' }]],
// Shard boundaries are computed from the full test list, so every shard
// must see an identical set of files — keep test discovery deterministic.
testDir: './tests',
fullyParallel: true,
});
Shard sizing has a knee. Each additional shard adds fixed overhead — runner acquisition, checkout, dependency install, cache restore, artifact upload — that is typically 60 to 120 seconds. Once the per-shard test time drops near that overhead, another shard buys nothing and costs a runner. Compute the break-even honestly: if the suite takes 18 minutes on one machine and the fixed overhead is 90 seconds, four shards land around 6 minutes and eight shards around 4, which is usually where teams stop. Playwright distributes by test count within files rather than by measured duration, so a suite with one very slow file will produce an uneven split no matter how many shards you add; splitting that file is worth more than another runner. The matrix definition, the artifact naming that keeps blobs from colliding, and the merge job are covered in Running Playwright Tests in GitHub Actions with Sharding.
Authenticating once and reusing the state
Every CI job starts with an empty browser profile, so a suite that logs in through the UI in each test pays the login cost hundreds of times and couples every test to the fragility of the sign-in form. The pattern that scales is a setup project: one job-scoped step signs in, serializes cookies and local storage to a JSON file, and every test project declares a dependency on it and loads that file as its starting storageState.
import { test as setup, expect } from '@playwright/test';
const AUTH_FILE = '.auth/user.json';
setup('authenticate once for the whole run', async ({ page }) => {
await page.goto('/login');
// Credentials come from the CI secret store, never from the repository.
await page.getByLabel('Email').fill(process.env.E2E_USER!);
await page.getByLabel('Password').fill(process.env.E2E_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
// Assert on a post-login signal before saving, so a redirect that is still
// in flight cannot serialize a half-written session cookie.
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
// Writes cookies plus origin-scoped localStorage into one portable file.
await page.context().storageState({ path: AUTH_FILE });
});
The config wires it up by declaring the dependency, which makes the ordering explicit rather than relying on file naming.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [
// Runs first; anything that depends on it waits for it to pass.
{ name: 'setup', testMatch: /global\.setup\.ts/ },
{
name: 'chromium',
dependencies: ['setup'],
// Every test in this project starts already signed in.
use: { ...devices['Desktop Chrome'], storageState: '.auth/user.json' },
},
],
});
Two CI-specific caveats apply. Sharding runs the setup project once per shard, because each shard is a separate machine with its own filesystem — that is correct behaviour, not waste, but it does mean the login endpoint sees one request per shard and any per-account rate limit must tolerate that. And the auth file is a live credential: never upload it as a job artifact, and add its directory to .gitignore so a local run cannot leak it into a commit. The full treatment of session reuse, token expiry inside long runs, and per-role isolation lives in Authentication & Session State, with the mechanics broken out in Setting Up an Auth Setup Project and Reusing Login State with storageState.
Starting the application from the config
A pipeline that launches the app in one shell step and the tests in the next has a race: the test step begins the moment the previous command returns, which may be before the server is accepting connections. Playwright's webServer option removes the race by owning the lifecycle — it starts the process, polls a URL until it responds, runs the suite, and tears the process down afterwards.
import { defineConfig } from '@playwright/test';
export default defineConfig({
webServer: {
command: 'npm run start:ci',
// Poll a real readiness endpoint, not the root route: a framework can
// serve HTML long before its database pool and migrations are ready.
url: 'http://localhost:3000/api/health',
// Locally, attach to whatever is already running; in CI always start clean.
reuseExistingServer: !process.env.CI,
// Cold starts on a shared runner are slow; 120s is a realistic ceiling.
timeout: 120_000,
// Forward server logs into the job output so a boot failure is visible
// in the same place as the test failure it caused.
stdout: 'pipe',
stderr: 'pipe',
},
use: { baseURL: 'http://localhost:3000' },
});
Point url at an endpoint that returns success only once the application's dependencies are genuinely up. A health route that checks the database connection and the migration version turns "my first test always fails, the rest pass" into a clear boot error with the server's own log attached. If the app under test is deployed to a preview environment instead of started in the job, drop webServer and set baseURL from the deployment's URL — but keep a readiness poll as a first step, because a preview URL can resolve in DNS before the container behind it is serving.
Artifacts on failure
A failed CI test that leaves nothing behind forces a re-run with more logging, which doubles the feedback loop and may not reproduce the failure at all. Configure the runner to emit a trace on first retry, a screenshot only on failure, and a video retained on failure, then upload those artifacts as part of the job. The trace is the highest-value artifact: it is a self-contained, time-travel recording of the DOM, network, and console that opens in the Trace Viewer, and it is the difference between reading a stack trace and watching the failure.
Set the retention policies deliberately. trace: 'on' records everything and will produce gigabytes on a large suite; on-first-retry records only the runs that already failed once, which is where the evidence is. Videos are the heaviest artifact per test, so retain-on-failure — which records then discards passing runs — is the only setting that scales. Upload with if: always() (or the provider's equivalent) so artifacts survive when the test step fails, which is precisely when you need them, and name the uploaded bundle with the shard index so three shards do not overwrite one file. How to capture, store, and read these is the subject of Reporters & Test Artifacts and, more narrowly, Capturing Screenshots and Video on Test Failure; reading the resulting file is covered in Analyzing Test Failures with the Playwright Trace Viewer.
Flake handling in pipelines
CI amplifies flakiness because it runs more often, on busier hardware, against shared environments. Two retries in CI absorb genuinely transient noise — a slow cold start, a momentary network blip — without masking real regressions, because Playwright classifies a test as flaky, not passed, when a retry rescues a first-attempt failure. That third outcome is the whole point of the retry policy: it converts an invisible intermittent failure into a labelled, countable event.
Treat that flaky verdict as a defect to triage, not a pass to ignore. A test that needs a retry is telling you about a missing wait, a shared fixture that leaked, or a non-deterministic dependency such as a live third-party API. Surface the flaky count in the merged report and put a number on it — flaky runs as a share of total runs, tracked per week — because without a metric the category grows silently until the team stops trusting red builds entirely. When a specific test resists a quick fix, move it out of the blocking path rather than raising the retry count for everyone, which is what Quarantining Flaky Tests Without Blocking CI describes. Route persistent offenders to Flaky Test Management for root-cause work; the two most common fixes — deterministic synchronization and bounded retries — are detailed in Detecting and Fixing Flaky Playwright Tests and Configuring Retries and Timeouts for Stable CI.
Containerizing the runner
The most reproducible CI environment is a container built on the official Playwright image, which ships the browsers and every OS dependency already installed and version-matched. This removes the entire class of missing-library failures and makes the same image usable locally, in CI, and on a self-hosted runner — the "works on my machine" argument disappears when both machines are the same image. It also changes the caching calculus: if the image already contains the browsers, the per-job browser cache becomes redundant and you cache the image layers instead.
The two pitfalls are both about the container runtime rather than Playwright. Chromium's default shared-memory allocation inside a container is 64 MB, which it will exhaust on a page with many tabs or large screenshots and then crash with an opaque "Target closed" error; running with --ipc=host gives it the host's shared memory and removes the failure. The second is user permissions: the official image runs as a non-root user, so any directory the tests write to — the auth state file, test-results, the blob output — must be writable by that user or the run fails at teardown, after the tests have already passed. The Dockerfile, the image tag pinning strategy, and both pitfalls in detail are walked through in Dockerizing Playwright for Headless CI.
Failure modes and debugging
Four failure signatures account for most CI-only breakage, and each has a distinct fingerprint.
Every test fails immediately with a navigation error. The application never came up. Check the webServer log that stdout: 'pipe' forwarded into the job output, and confirm the readiness URL returns success rather than a redirect — a 302 to a login page counts as a response and will satisfy a naive poll.
One shard fails, the others pass, and the failing test passes when re-run alone. The suite has cross-test coupling that the shard split exposed: a test that depended on a record another test created now lands on a different machine. The fix is data independence — each test seeds what it needs — rather than pinning tests to shards.
Timeouts that move around the suite between runs. Resource contention, not application bugs. Reduce workers, check the runner's memory ceiling, and look at whether the traces show long gaps with no activity, which is the signature of a starved process rather than a slow request.
Passes on retry, always, on the same handful of tests. A real synchronization defect. Open the trace from the failed first attempt — that is exactly what trace: 'on-first-retry' preserves — and look at what the action timed out waiting for. In practice it is almost always an element that existed but was covered, animating, or replaced mid-interaction.
For anything that survives this triage, the trace file is the primary evidence and the merged HTML report is the index into it. Download both from the failing run before re-running the job, because a re-run replaces the artifacts of the run you were investigating.
Deep dives beneath this guide
Three narrower walkthroughs sit under this guide and each takes one of the sections above down to the level of a working file you can copy.
- Caching Playwright Browsers in CI — how to build a cache key that invalidates exactly when the browser revision changes, and how to verify the restore actually hit instead of quietly re-downloading.
- Running Playwright Tests in GitHub Actions with Sharding — the shard matrix, unique blob artifact names, and the dependent merge job that produces one report and one exit code.
- Dockerizing Playwright for Headless CI — building on the official image, pinning the tag to your Playwright version, and the
--ipc=hostand file-permission traps.
Frequently Asked Questions
How many workers and shards should I use?
Set workers to match the CPU of a single runner — two to four on a typical two-core CI machine — and add shards to cut wall-clock time across machines. Total concurrency is workers multiplied by shards, so three shards of four workers gives twelve-way parallelism. Add shards until the per-job overhead of checkout, install, and cache restore stops being worth the time saved, which for most suites is between four and eight.
Why do my browsers re-download on every CI run?
The browser cache key does not match between runs, so the restore step misses every time. Key the cache on the resolved Playwright version so a version bump invalidates the cache deliberately and an unchanged version reuses the binaries. After a cache hit, the install command only verifies the binaries instead of downloading them. Include the platform and architecture in the key as well, since browser builds are not portable between runner images.
Should CI retries be allowed at all?
Yes, a small bounded number such as two, but only in CI and only as a signal. A retry that rescues a failing test marks it flaky, which is a defect to fix rather than a result to celebrate. Track the flaky count as a share of total runs and treat a persistently flaky test as a missing wait or a non-deterministic dependency. Raising the retry limit to hide a known-bad test is how a suite loses its credibility.
Why does my suite pass locally but time out in CI?
The runner changes the timing distribution: fewer cores, shared network, and a cold application mean operations that took milliseconds on the laptop now take seconds. Tests that relied on incidental speed rather than explicit synchronization fail first. Look for fixed sleeps, assertions that fire before a request settles, and interactions with elements that are present but still animating, and replace them with web-first assertions that retry until the condition holds.
Do I need a container image, or is installing dependencies enough?
Installing with --with-deps is enough for a hosted runner on a supported Linux image and is the simpler path. Reach for the official container when you run self-hosted runners with unpredictable base images, when you need the exact same environment locally and in the pipeline to reproduce a failure, or when dependency installation has become a meaningful share of job time and you would rather bake it into a layer.
How do I get one verdict when tests run on many machines?
Use the blob reporter on every shard and add a final job that depends on all of them, downloads each blob artifact, and runs merge-reports. The merge produces a single HTML report and a single exit code that the branch protection rule can read. Without it each shard publishes its own report, and a reviewer has to open three of them to learn whether the change is safe to merge.