undo
Go Beyond the Code
arrow_forward_ios

Why E2E Tests Fail in CI But Pass Locally

Gabriel Gidio Huertas Fiestas
Software Engineer & Solver
September 17, 2026
To learn more about this topic, click here.

Modern web applications rely on dynamic rendering, asynchronous state updates, and re-renders—behaviors that often break E2E tests written without proper synchronization. The result is a familiar problem: tests that pass locally but fail unpredictably in CI, reducing confidence in the test suite and the QA process. This article examines common anti-patterns that cause flakiness, such as hardcoded waits, unstable selectors, and missing state checks. For each, it presents a reliable alternative using Playwright’s built-in mechanisms. Through practical TypeScript examples, the goal is to provide patterns you can apply directly to stabilize your test suite.


1. Introduction

End-to-end tests sit at the top of the testing pyramid. They are the closest thing to a real user interacting with a real application—and precisely because of that, they are the most expensive to write, the hardest to maintain, and the most damaging when they lose credibility.

The loss of credibility follows a predictable pattern. A test starts failing intermittently in CI. Someone re-triggers the pipeline, and it passes. After a few cycles, the team learns to re-trigger instead of investigate. CI becomes a formality rather than a quality gate—and the suite that was supposed to prevent regressions is now just friction between a developer and a merge button.

The root cause is almost never a bug in the application. It is a test that makes assumptions about timing, environment, or state that are true locally and false in CI. Those assumptions—implicit, invisible, and consistent—produce tests that appear to work until the conditions change. And in CI, conditions are always different.

This article identifies eight patterns responsible for most of that unreliability. Each section describes what the symptom looks like, why it happens, and what a reliable Playwright-native replacement looks like. The goal is not a comprehensive framework—it is a focused set of decisions you can apply immediately to a real test suite.

The Local/CI Gap: More Than Bad Luck

You run the test. It passes. You push. CI fails. You re-trigger. It passes. This cycle is not random—it is a symptom of a test that relies on environmental conditions it should never depend on.

Locally, your machine is fast, the network is stable, the browser cache is warm, and you are watching the test run. CI is the opposite: constrained CPU, cold state, headless browser, and parallel execution all at once. Tests that pass locally often make silent assumptions about speed and state that CI cannot guarantee.

The math makes this worse than it looks. A suite with a 1.5% per-test flakiness rate seems manageable—until you have 300 tests. At that scale, the probability of a fully green run drops below 1%. At 500 tests, it is under 0.1%. Flakiness does not add up linearly; it compounds.

The patterns below are the most common causes of this gap, ordered from general to specific. Each has a direct, Playwright-native fix.


3. Anti-Pattern 1: Hardcoded Waits

Symptom in CI: Tests time out or stay flaky even after someone added waitForTimeout(3000) to “fix” the problem. The wait is either not long enough under CI load, or needlessly slow when the element was ready in 200ms.

Root cause: A hardcoded sleep is a guess. CI varies in speed and load, so the guess is wrong in both directions.

Playwright performs automatic actionability checks before every interaction—visible, stable, enabled, not obscured—and web-first assertions like toBeVisible() and toHaveText() retry until the condition is met or the timeout expires. There is no valid reason to use waitForTimeout().



            


4. Anti-Pattern 2: Fragile Locators

Symptom in CI: “Element not found” after a UI refactor that changed no user-facing behavior. Locally the old DOM may still be cached; CI starts fresh every time.

Root cause: Selectors tied to CSS class names or DOM structure break when developers restyle or reorganize markup. These changes are invisible to users—but they break tests coupled to implementation details.

Playwright’s locator guide establishes a clear priority order. Role-based locators come first because they reflect how users and assistive technology interpret the page. If a developer changes a <button> to a <div role="button">, getByRole still works. A CSS chain breaks.



            


Role-based locators cover most cases, but in large or complex pages a single role-based locator can match more than one element. When that happens, the instinct is to reach for a CSS selector. The better path is to coordinate with developers to add data-testid attributes on key interactive elements:



            


data-testid attributes have one job: automation targeting. They do not change with styling or layout, and they make it immediately clear to any developer that removing or renaming them affects the test suite.

For cases where a role or testid still matches multiple elements, chain locators to narrow the search to a specific container:



            

Playwright’s strict mode enforces that every locator resolves to exactly one element. An ambiguous locator fails immediately—which is correct.


5. Anti-Pattern 3: No Network State Confirmation

Symptom in CI: A button is clicked, but the expected outcome never appears. Or the test passes locally because the API is fast, but times out in CI where network responses take longer.

Root cause: The test triggers a network call and immediately moves on—without confirming the call completed. CI has no warm cache and no predictable network speed. Asserting on state that may not exist yet is a race condition.

The instinctive fix is a hardcoded wait, which brings the same problem as Anti-Pattern 1:



            


The correct fix is waitForResponse. The critical rule: register the promise before the action that triggers it, then await both. If you register it after the click, the response may have already arrived and you miss it entirely.



            

This applies to any action that triggers a network call: form submissions, filters, deletions, navigation events. The underlying principle is the same as Anti-Pattern 1—do not guess at timing—but the mechanism is specific to network state rather than UI element state.


6. Anti-Pattern 4: When Auto-Wait Is Not Enough

Symptom in CI: The element is present and visible, but its content is stale. An assertion catches the element mid-update, or a value that should have changed has not propagated yet.

Root cause: Two distinct scenarios produce this. First, components in frameworks like React may appear in the DOM during server-side rendering but remain non-interactive until client-side JavaScript finishes hydrating them. Second, async backend pipelines—search indexing, cache propagation—can take several seconds to reflect a write that the UI already confirmed as complete.

Playwright’s auto-wait handles DOM-level readiness but cannot know when a value is finally correct. That is the use case for toPass(): retrying an entire block of assertions until it passes.



            


For UI re-renders specifically—such as a dropdown that re-renders on selection—use short inner timeouts so retries do not pause unnecessarily:



            

The intervals option controls the polling cadence. Keep inner assertion timeouts short—around one second—so toPass can cycle quickly. The outer timeout governs the total allowed wait.


7. Anti-Pattern 5: Shared and Polluted Test Data

Symptom in CI: Tests pass individually, fail when run together. The failure is non-deterministic—sometimes one test, sometimes another—and impossible to reproduce reliably.

Root cause: Tests read and write the same database records. When tests run across different files in parallel, one test modifies a record while another expects it in its original state. The result is a data race that looks like a product bug but is a test design problem.

The fix is to give each test its own data and clean it up afterward. Playwright fixtures handle both through the await use() pattern: everything before use() is setup, everything after is teardown—and the teardown runs even if the test throws an exception.



            


The test receives a fresh, isolated product and does not need to know how it was created or cleaned up:



            

Using Date.now() in the product name ensures no naming collisions across parallel workers, even on a shared database. Each test run creates its own records and cleans them up independently.


8. Anti-Pattern 6: Testing the Setup, Not the Feature

Symptom in CI: The test fails during login, or during a step that creates precondition data through the UI. The feature being tested works—the test just never gets there.

Root cause: Every UI step is a potential failure point unrelated to what the test validates. A test that navigates through the UI to log in, creates prerequisite data through the UI, and then reaches the actual feature has stacked three layers of risk before a single assertion on the feature runs.

The boundary is simple: use the UI only for what the test is explicitly validating. Everything outside that boundary should take the fastest, most reliable path available.

For data that a test depends on but is not testing, create it through the API:



            


For authentication, the same principle applies. When login is not the feature under test, avoid repeating the full UI login flow in every test. Playwright’s storageState authenticates once per account and reuses that session—you can maintain separate state files for different user roles:



            

The result is a test that starts exactly where it should: at the feature being validated.


9. Anti-Pattern 7: Misunderstanding Playwright’s Parallelism Model

Symptom in CI: Tests that pass individually fail when the full suite runs. beforeAll appears to execute more than once. Data collisions appear between tests in the same file.

Root cause: Playwright’s default behavior is to run test files in parallel across workers, while tests within a single file run sequentially in one worker. Running multiple workers against the same spec file causes beforeAll to execute simultaneously in each worker—producing concurrent writes to the same database records.



            


With fullyParallel: false, tests within a file are safe. The risk is explicitly running multiple workers against a single spec file:



            


When your suite grows and CI execution becomes slow, use sharding to distribute files across separate machines rather than increasing workers per machine:



            


Combine with blob reporting to merge results from all shards into a single report:



            


10. Anti-Pattern 8: Locators Scattered Across Test Files

Symptom in CI: A single UI change breaks twenty tests that reference the same element in different ways. Fixing it requires searching across the entire codebase. The test suite becomes something no one wants to maintain.

Root cause: When locators and interaction logic live in test files rather than dedicated objects, a single UI change forces multiple manual updates. The more tests there are, the worse the blast radius.

The Page Object Model addresses this by centralizing selectors as locator getters and exposing user workflows as methods. Tests call the method; the locator lives in one place.



            


The goal is not a complex framework. A simple structure where each locator exists in exactly one place is enough. Deep inheritance, abstract base classes, and generic utility layers tend to produce a different version of the same maintenance problem.


11. Test Observability: Step Tracing and Lifecycle Tags

test.step() — Know Exactly What Failed

When a test fails in CI, the most useful information is not that it failed—it is which step failed and what the UI looked like at that moment. test.step() wraps logical phases into named units that appear in the trace viewer and HTML reports.No configuration required. Wrap logical sections of your test:



            


The { box: true } option makes error messages point to the step call site rather than deep inside a Page Object method—useful when the same method is called from many tests and you need to know which test triggered the failure:



            


Test Lifecycle Tags — Manage Flakiness Without Hiding It

Playwright’s annotation system gives you the building blocks for a lightweight test lifecycle system. Without it, flaky tests become noise; quarantined tests that never get reviewed become permanent debt.

Define a tag vocabulary and filter by it in CI:



            


Filter in CI:



            


Tag reference:

Tag Meaning CI behavior
@ready Stable — must pass Runs, blocks merge
@flaky Quarantined — under investigation Excluded; tracked separately
@pending-dev Blocked by a known bug, dev-side fix needed Skipped with issue link
@pending-qa Test needs QA investigation Skipped with description
@deprecated Feature removed or replaced Scheduled for deletion

The quarantine contract: @flaky always includes an issue link and is reviewed every sprint. A test that stays quarantined for two sprints without action is either fixed or deleted. Quarantine is a temporary state, not a solution.


12. Validate Locally Before Pushing

A test that passes once is not necessarily stable. Before pushing any new or modified test to CI, run it repeatedly to confirm there is no hidden flakiness:



            


Five to ten repetitions is enough to surface most flaky patterns—race conditions, timing issues, state leakage. A test that fails even once in ten runs should not go to CI. Fix it first.

13. Playwright Reliability Checklist


14. Conclusions

Flakiness is a design problem, not a luck problem. Every intermittent CI failure has a root cause. The patterns in this article—hardcoded waits, unstable selectors, missing network confirmation, shared data—are not edge cases. They are the default outcome when tests grow without explicit standards. The environment does not break your tests; the assumptions baked into them do.

A test that lies is worse than no test. A suite that regularly passes a broken build trains the team to ignore CI signals. The goal is not maximum test count—it is maximum signal quality. Ten reliable tests that always mean something are worth more than a hundred that occasionally pass for the wrong reasons.

Playwright gives you the right tools; the discipline has to come from the team. Auto-waiting, web-first assertions, waitForResponse, toPass(), fixtures with await use()—none of these require complex setup. The barrier is not tooling. It is establishing a shared standard and enforcing it consistently: through code review, lint rules, and a quarantine policy that treats @flaky as temporary, not permanent.

Isolation is the foundation everything else depends on. Tests that share data will eventually conflict. Tests that do too much in setup will eventually fail on something unrelated to the feature. The more aggressively you isolate—data per test, API for preconditions, storageState for sessions—the fewer false positives you will see in CI.

Observability is what separates a suite you trust from one you tolerate. Step tracing, lifecycle tags, and a consistent naming convention are not overhead. They are the difference between a CI failure that gets fixed in five minutes and one that gets ignored for two sprints. Build observability in from the start; retrofitting it is significantly harder.

The local/CI gap closes when you stop treating CI as a stricter version of local. CI is not just faster or slower—it is a fundamentally different environment: cold state, constrained resources, no warm cache, no human watching. Tests that account for that reality from the beginning do not suddenly fail when you push. That is the standard worth building toward.

Gabriel Gidio Huertas Fiestas
Software Engineer & Solver
Arrow icon go to top

Start Your Digital Journey Now!

Which capabilities are you interested in?
You may select more than one.
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.