June 8, 2026
How to Debug Playwright Tests That Fail Only in CI but Never Locally
A practical guide to debugging Playwright tests that fail only in CI, covering timing issues, environment drift, browser version mismatches, parallelism, and hidden test dependencies.
When a Playwright test passes on your laptop and fails in CI, the failure is usually telling you something real, but not always something obvious. The test may be too dependent on timing, the CI environment may differ from local in subtle ways, or the suite may contain shared-state assumptions that only break under parallel execution. These issues are common in browser automation, and they are especially painful because the failure looks random until you connect it to how your pipeline actually runs.
If you are dealing with Playwright tests that fail only in CI, the goal is not just to make the test green once. The goal is to identify the class of mismatch between local and CI, then remove the underlying source of instability. That usually means debugging the environment, the test data, the browser runtime, and the way tests are scheduled, not just adding another wait.
Start with a simple rule: reproduce the CI conditions as closely as possible
The fastest way to waste time is to debug a CI-only failure with local assumptions. Your laptop, your network, your screen size, your CPU availability, and your browser build may all be different from the runner that executed the failing job. Start by making local execution resemble CI as much as you can.
At minimum, compare these variables:
- Playwright version
- Browser version and channel
- Operating system and container image
- Headless vs headed mode
- CPU and memory limits
- Base URL or environment variables
- Parallelism and worker count
- Locale, timezone, and viewport
A test that passes on a macOS laptop with one browser worker can fail in a Linux container running four workers and a smaller viewport. That does not mean the test is bad in theory, it means the test is sensitive to conditions that your team has not made explicit.
CI-only failures are often symptoms of hidden assumptions. The more implicit your test setup, the more likely CI will expose it.
A good first move is to capture the exact failing command from CI and run it locally with the same environment variables and the same browser project. If your CI uses Docker, use the same image. If it uses a GitHub Actions runner, use a comparable Linux environment with the same installed dependencies. If you cannot mirror everything, at least reduce the problem to the smallest reproducible difference.
The most common mismatch patterns
The phrase “local vs CI test failures” sounds broad, but in practice the failures usually fall into a handful of categories.
1. Timing and synchronization problems
This is the most common category. The test waits for the wrong thing, or it depends on an event that happens fast enough locally but slower in CI.
Examples include:
- Clicking before the element is truly interactive
- Asserting on UI state before the application finished rendering
- Waiting for network responses without tying them to the user action that triggered them
- Assuming a modal, toast, or animation has finished when it has not
Playwright helps more than older browser automation tools because it waits for many actionability checks automatically, but that does not eliminate timing bugs. If the application itself is slow, loading asynchronous data, rendering after hydration, or performing client-side redirects, the test can still race the UI.
A brittle example looks like this:
typescript
await page.click('text=Save');
await expect(page.locator('text=Saved')).toBeVisible();
If the click triggers an API call, the visibility assertion may succeed locally and fail in CI when the request is slower. A better pattern is to wait on the application signal that actually represents completion:
typescript
await Promise.all([
page.waitForResponse(resp => resp.url().includes('/api/save') && resp.ok()),
page.getByRole('button', { name: 'Save' }).click()
]);
await expect(page.getByText('Saved')).toBeVisible();
That still might not be enough if the UI renders the confirmation after a separate state update. In that case, you may need to assert on a deterministic DOM change, not a transient visual state.
2. Environment drift
A test that depends on environment drift usually passes until the CI environment changes. This includes browser updates, Node.js updates, OS package changes, locale differences, and secrets or config values that differ between local and pipeline.
Common examples:
- Timezone-sensitive formatting checks
- Date-based assertions that depend on the current day in UTC versus local time
- Locale-specific text or number formatting
- Feature flags enabled locally but disabled in CI
- API endpoints or mock servers configured differently across environments
If your app renders timestamps, relative dates, currency, or translated content, write the test so it does not depend on the machine locale unless that is what you are explicitly testing. CI often defaults to a different timezone or locale than your workstation.
For browser-based tests, remember that headless Linux environments can behave differently from a GUI desktop. Fonts, scrollbars, viewport behavior, and anti-aliasing can all affect visibility and layout assertions. That is one reason browser automation flakiness often appears only in CI, where rendering and timing are just different enough to expose the bug.
3. Browser version differences
Playwright downloads and manages browser binaries, but version alignment still matters. If local developers are running one Playwright version and CI is pinned to another, the bundled browser versions may differ as well. Even small browser changes can alter layout, pointer behavior, file downloads, popup handling, and media query results.
If the same test fails only in CI, compare the installed Playwright version and browser revision locally and in the pipeline. Keep this in your logs or build output. A version mismatch can look like a flaky test when it is really an unpinned dependency problem.
This also matters when teams mix browser channels. A test that passes in Chromium local but runs against WebKit or Firefox in CI may reveal compatibility issues only in that browser. If your pipeline is supposed to cover multiple engines, do not treat a cross-browser failure as noise. It may be the first signal of a real production bug.
4. Parallelism and shared state
Parallel execution is one of the biggest sources of CI-only failures because it exposes hidden coupling.
Typical symptoms:
- Tests pass individually but fail when run together
- Auth state leaks between tests
- Two workers create the same user or invoice
- A shared database record is mutated by multiple tests
- A cleanup step deletes data another test still needs
This kind of failure often disappears locally if you run one file at a time or use a single worker. CI, however, runs the full suite with multiple workers to reduce runtime, and that reveals the dependency.
The fix is usually not to disable parallelism globally. Instead, isolate the state each test needs. Use unique test data, per-test storage state, separate tenants or namespaces where possible, and explicit cleanup. If tests must share fixtures, make the fixture read-only or scope it carefully.
If you suspect a race condition, rerun the suite with a single worker in CI-like conditions. If the issue disappears, you likely have a state isolation problem, not a browser issue.
5. Hidden test dependencies
Hidden dependencies are tests that rely on order, previous side effects, or a pre-populated database. They often pass on a developer machine because the local environment is “already warm” from previous runs.
Examples include:
- A test assumes a user account already exists
- A spec depends on seeded data created by another spec
- A test reuses cookies, localStorage, or auth tokens from a previous run
- Cleanup is incomplete, so the next run starts with leftover state
This is especially dangerous in CI because the job may run from a clean workspace, a fresh container, or a different database snapshot. What looked like a feature test is actually an order-dependent integration test.
A reliable suite makes each test explicit about its setup. If a test needs a user, create the user in a fixture or API setup. If it needs authenticated state, store it in a fresh storage state file or login through a reusable helper, not through a previous test.
A practical debugging workflow
When a test fails only in CI, use a structured approach instead of adding random retries.
Step 1: Get the exact failing artifact
Keep the test output, trace, screenshot, and video if your CI job captures them. Playwright can produce traces that are much more useful than a raw stack trace because you can inspect the DOM, network, and action timeline.
A representative CI config might look like this:
- name: Run Playwright tests
run: npx playwright test --trace=on-first-retry
If the failure is intermittent, trace on first retry is useful, but for debugging a stubborn failure, you may want trace on every run in a temporary branch or a dedicated debug job.
Step 2: Run the same test in headless mode locally
Many developers debug CI-only failures in headed mode on a fast laptop. That is useful, but not enough. CI generally runs headless, and headless mode can change timing and layout.
Try the exact command CI uses, including the same browser project and reporter settings. If the job uses environment variables, export them locally. If the job runs through a shell script, run that script instead of reproducing the steps manually.
Step 3: Reduce the test to the smallest failing action
A long end-to-end test may fail at the last step, but the root cause may be much earlier. Split the flow mentally into setup, action, and assertion. Identify which part first diverges from expectation.
Useful questions:
- Does the page load the expected route?
- Does the network call complete successfully?
- Does the expected element appear before the assertion?
- Is the test failing during interaction, navigation, or verification?
Once you know the phase, inspect the trace around that point. For example, if the test fails right after a click, check whether the target was covered by a dialog, whether the button was disabled, or whether a navigation interrupted the click.
Step 4: Compare environment details side by side
Print the runtime details in CI and locally. You want to know if the failure correlates with a different browser binary, OS image, timezone, or worker count.
A small diagnostic block can help:
console.log({
browserName: process.env.PLAYWRIGHT_BROWSERS_PATH,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
locale: Intl.DateTimeFormat().resolvedOptions().locale
});
For broader CI metadata, also log the Playwright version, Node version, and container image tag. If you use a monorepo, verify that the test package is not picking up an unexpected lockfile or dependency cache.
Step 5: Re-run in isolation and with controlled parallelism
Run the test file alone, then run the entire suite with one worker, then with the normal worker count. If the failure only appears under concurrency, focus on state isolation. If it appears only in a specific shard, look for order dependencies or cleanup issues in that shard.
How to harden tests against CI-only failures
Once you understand the cause, the fix should reduce sensitivity, not just silence the symptom.
Prefer user-facing signals over implementation timing
Assertions should verify what the user can actually observe. For example, verify a button becomes enabled, a route changes, or a confirmation is visible. Avoid asserting on arbitrary sleeps, internal JS state, or network completion unless the test is explicitly about that behavior.
Avoid this pattern:
typescript
await page.waitForTimeout(3000);
If you need that wait, it often means the test lacks a proper condition. A fixed delay is not synchronization, it is an assumption.
Use deterministic test data
Generate unique data per test run, especially for usernames, email addresses, and object names. If your backend has eventual consistency or delayed cleanup, build the test around a namespace or test ID so it does not collide with concurrent runs.
A common pattern is to derive a unique suffix from the test title or timestamp. Even better, create data through an API or fixture that returns the exact record the test should use.
Isolate authentication state
Authentication is a frequent source of hidden dependencies. Reusing a shared logged-in session can speed up suites, but it also creates cross-test coupling if the session is mutated or expires unexpectedly.
If you use Playwright storage state, make sure it is generated in a controlled setup step and not reused beyond the scope it was designed for. Refresh it when the auth schema or cookies change.
Pin the toolchain intentionally
Do not let the browser runtime drift casually. Pin the Playwright version, understand how browser binaries are installed in CI, and keep your Docker image or runner image under version control. A stable test pipeline is not one that never updates, it is one where updates are explicit and observable.
Keep the viewport and execution mode stable
A surprisingly large number of browser automation flakiness issues trace back to layout changes between local and CI. Explicitly set the viewport, device scale factor if needed, and browser project. If your app uses responsive layouts, test them deliberately instead of letting CI choose a viewport implicitly.
A few concrete root causes and fixes
Case 1: Test passes locally, fails in CI after clicking a button
Likely cause, the button is being clicked before the page finishes rendering or while an overlay is still present.
Fix, wait for the specific element that confirms the page is ready, then click. If necessary, assert on button enabled state before interaction.
Case 2: Test only fails in CI on Firefox or WebKit
Likely cause, browser-specific rendering or event handling difference.
Fix, inspect the trace in the failing browser, then check whether your selector is too brittle, whether the element is offscreen, or whether the app depends on Chromium-only behavior.
Case 3: Test passes alone, fails in the full suite
Likely cause, test ordering or shared state.
Fix, run with one worker, identify data collisions, and move setup into per-test fixtures. Make cleanup explicit and idempotent.
Case 4: Assertion fails only on CI because text is different
Likely cause, locale, timezone, feature flags, or seeded content drift.
Fix, normalize the environment or make the assertion locale-aware. For dates, assert against known formatted output for the configured timezone, not the workstation timezone.
What not to do
It is tempting to patch CI-only failures with retries, longer timeouts, or broader selectors. Those changes can reduce noise temporarily, but they can also hide real instability.
Retries are useful for classifying flaky failures, but they are not a root cause fix. If a test needs three retries to pass, it is still telling you that the system under test or the test itself is too fragile.
Similarly, broad selectors like div >> text=Save may make the test pass today, but they often fail later when the layout changes. Use stable, user-meaningful locators where possible, such as roles, labels, and test IDs when appropriate.
A debugging checklist you can reuse
When a Playwright test fails only in CI, ask these questions in order:
- Is the browser version the same locally and in CI?
- Is the test running in the same mode, headless or headed?
- Is the viewport, locale, and timezone identical?
- Does the failure happen with one worker?
- Does the failure happen in one browser or all browsers?
- Is the test waiting for the right condition?
- Does the test depend on data created by another test?
- Does the CI environment use different feature flags or credentials?
- Can I reproduce the issue locally using the same container or runner image?
- Does the trace show the app or the test moving faster or slower than expected?
If you cannot answer these questions confidently, the suite is probably too implicit. Making the runtime and the test data more explicit will save more time than adding more retries.
Building a more reliable CI pipeline
The best long-term fix for CI-only failures is to make the pipeline observability rich enough that failures are easy to classify. Capture traces, screenshots, logs, and browser metadata on failure. Keep the test environment pinned. Run the same browser matrix in a controlled way, not accidentally. Separate fast smoke checks from broader end-to-end coverage so that the most important paths are easy to debug.
This is where continuous integration matters as a practice, not just a tool, because the value of CI is not only in catching regressions early, but in making the software and its tests converge on a repeatable execution model. The less surprise in the pipeline, the less time you spend guessing at flaky failures.
Conclusion
If your Playwright tests fail only in CI, assume the test is exposing a real mismatch between environments until proven otherwise. The most common causes are timing issues, environment drift, browser version differences, parallelism, and hidden dependencies. Treat each failure as a clue about how your suite interacts with the runtime, not just as a random red build.
The practical path is straightforward, even if the debugging is not: reproduce CI locally, inspect traces, compare environment details, isolate parallelism, and remove implicit assumptions from your tests. Once you do that, CI stops being a mystery box and starts behaving like a useful signal for browser automation reliability.
For teams that want a broader foundation, it can help to revisit the basics of test automation, continuous integration, and how modern browser automation differs across engines and runtime environments. The more your team treats the browser as a real runtime, not a mocked convenience, the fewer surprises you will get when a test leaves your laptop and enters the pipeline.