Paul S.

Playwright Migration — Unlocking CI/CD

Replacing an incumbent Cypress/Selenium E2E stack with Playwright across a 30+ app Nx monorepo, taking coverage from ~5% to ~80%, including a POS/Swift app that had never had an automated test at all.

Context

I owned the Cypress E2E suite and CI/CD across a 30+ app Nx/React/TypeScript monorepo. Coverage sat at roughly 5%, and the tests that did exist were flaky enough that a red run was more likely to start an investigation into the test than into the code.

Problem

The suite cost the same to run as a reliable one, cost more to maintain, and trained the team to ignore failures. Nothing downstream could depend on those tests as a gate, so they were not protecting anything.

What I did

  • Made the case for Playwright over the incumbent Cypress/Selenium stack on auto-waiting, DevTools-protocol control, and reach onto surfaces the old stack could not test at all.
  • Backed the argument by taking coverage from ~5% to ~80%.
  • Brought a POS/Swift application under automated test coverage for the first time, from zero.
  • Built a self-hosted dashboard for navigating test executions across repositories, so a failing run could be found and read without digging through CI logs per repo.
  • Mentored the team on test-design patterns, so the migration produced durable tests instead of the same flakiness in a new framework.

Why the existing suite was flaky

The existing Cypress suite made the test author responsible for deciding when the page was ready. Every test therefore encoded a timing assumption that held on a developer laptop and broke on a loaded CI worker.

// Waits are the author's problem: every timing assumption is a
// future flake on a slower CI box.
cy.get('[data-test=place-order]').should('be.visible')
cy.wait(500)                            // makes it pass locally
cy.get('[data-test=place-order]').click()

// Waits are the framework's problem: actionability is re-checked
// until the element can genuinely be clicked, or it fails honestly.
await page.getByTestId('place-order').click()
The `wait(500)` is a guess about someone else's machine, and it is why the suite failed differently on every run.

Auto-waiting removes that category of failure instead of mitigating it, which was the core of the technical argument. Control over the browser through the DevTools protocol was the second half, because it reached surfaces the old stack could not drive at all.

The surfaces the old stack couldn't reach

A POS/Swift application sat at zero automated coverage, because the incumbent stack had no way to drive it.

That changed what the evaluation was about. The comparison stopped being whether Playwright was enough better than Cypress to justify a migration, and became which framework could test the applications the team shipped.

Adoption

Swapping the framework without changing how tests are designed reproduces the same flakiness in new syntax. A team that writes Cypress-shaped tests in Playwright concludes the new tool is no better and stops maintaining the suite again.

So the migration was as much mentoring as engineering. We worked through test-design patterns together: what belongs in an end-to-end test and what belongs a level down, how to select elements so tests survive refactors, and how to model setup so tests do not depend on each other's leftovers. That is what kept the coverage number from decaying back.

Once a red run reliably meant broken code, the suite could be used as a gate instead of a report.

Result

  • E2E coverage ~5% → ~80% across a 30+ app Nx/React/TypeScript monorepo.
  • A POS/Swift application brought under automated coverage for the first time, from zero, on a surface the incumbent stack could not reach.
  • Flakiness addressed at its cause via auto-waiting, so a failing run became a signal about the code instead of about the test.
  • Adoption that stuck, because the team was mentored on test-design patterns instead of just handed a new framework.

Tech

  • Playwright
  • TypeScript
  • Nx monorepo
  • React
  • Cypress
  • Selenium
  • CI/CD