50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
best-practicesMay 24, 2026·8 min read

Playwright vs Cypress in 2025: We Switched and Here's What Happened

We migrated a full test suite from Cypress to Playwright. Here's the honest breakdown — what's better, what's worse, and what nobody tells you.

Robert Seghedi

Robert Seghedi

Co-founder, peal.dev

Playwright vs Cypress in 2025: We Switched and Here's What Happened

We used Cypress for years. Loved the dashboard, loved the time-travel debugger, tolerated the flakiness. Then Playwright 1.40 dropped and everyone started talking about it again, so we spent a weekend migrating a test suite and documented everything that surprised us.

This isn't a feature comparison table. You can find those anywhere. This is about what it actually feels like to write and maintain tests day-to-day in 2025, what breaks when you switch, and which tool you should probably pick depending on your situation.

Where We Started: A Cypress Test Suite with Problems

The project was a Next.js SaaS app — auth flows, Stripe checkout, a bunch of dashboard CRUD. Around 80 Cypress tests. CI was taking 12 minutes on a good day, 20+ on a bad one because of flaky selectors and network timing issues. The classic Cypress experience, honestly. Great tooling, but you spend a non-trivial amount of time fighting the framework instead of writing tests.

The specific pain points were: tests that pass locally and fail in CI for no reason, iframes being a nightmare (hello, Stripe Elements), and the fact that Cypress runs inside the browser which means some things that should be simple just... aren't. Intercepting fetch requests felt clunky. Multi-tab flows were impossible.

The Playwright Setup Experience

First thing: setup is painless. `npm init playwright@latest` and you're running tests in 5 minutes. It scaffolds a config, gives you example tests, and the VS Code extension is genuinely excellent — better than the Cypress one. You get a test recorder, inline test results, and a codegen tool that actually produces decent selectors.

// playwright.config.ts — a reasonable starting config for Next.js
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 4 : undefined,
  reporter: 'html',
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    // Add Safari/Firefox when you actually need cross-browser
    // Don't add them just because you can
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

The `webServer` option is genuinely great — it starts your Next.js dev server automatically before tests and tears it down after. Cypress has something similar now but Playwright's implementation feels more solid. In CI you just run `npx playwright test` and it handles everything.

Writing Tests: The Syntax Difference

Cypress has a jQuery-inspired chainable API. It's readable but the async model is weird — commands queue up and execute later, so you can't do `const text = cy.get('.thing').text()` and use `text` on the next line. Everything has to be inside `.then()` callbacks or you'll get burned. Playwright uses real async/await. It's just TypeScript. No mental model shift needed.

// Cypress version of a login test
it('logs in successfully', () => {
  cy.visit('/login');
  cy.get('[data-testid="email"]').type('test@example.com');
  cy.get('[data-testid="password"]').type('password123');
  cy.get('button[type="submit"]').click();
  cy.url().should('include', '/dashboard');
  cy.get('h1').should('contain', 'Welcome back');
});

// Playwright version of the same test
import { test, expect } from '@playwright/test';

test('logs in successfully', async ({ page }) => {
  await page.goto('/login');
  await page.getByTestId('email').fill('test@example.com');
  await page.getByTestId('password').fill('password123');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await expect(page).toHaveURL(/dashboard/);
  await expect(page.getByRole('heading', { level: 1 })).toContainText('Welcome back');
});

Notice `getByRole` and `getByTestId` — Playwright's locators are accessibility-aware by default. `getByRole('button', { name: 'Sign in' })` is both more resilient to UI changes and basically forces you to write accessible markup. If you can't find an element by role, maybe that element needs an aria-label.

The Flakiness Problem: Playwright Wins Here

This is the biggest practical difference. Playwright's auto-waiting is smarter. When you call `page.click()`, it waits for the element to be visible, enabled, stable (not animating), and not obscured. Cypress waits too, but it's more... optimistic. You end up sprinkling `cy.wait(500)` around like it's a seasoning. We had 7 hardcoded waits in our Cypress suite. We have zero in the Playwright version.

CI run time went from 12 minutes average to 4 minutes, mostly from parallelism but also from not retrying flaky tests. Playwright runs tests in parallel by default across files. Cypress parallelism requires the paid dashboard. That's... a meaningful difference if you're a small team.

// Testing a flow that involves waiting for an API response
// Playwright makes this natural with route interception

test('shows loading state then results', async ({ page }) => {
  // Intercept and delay the API call
  await page.route('/api/search*', async (route) => {
    await new Promise(resolve => setTimeout(resolve, 500));
    await route.fulfill({
      json: { results: [{ id: 1, name: 'Test Result' }] },
    });
  });

  await page.goto('/search');
  await page.getByPlaceholder('Search...').fill('test');
  await page.keyboard.press('Enter');

  // Check loading state appears
  await expect(page.getByTestId('loading-spinner')).toBeVisible();

  // Then results appear (auto-waits, no manual timeout needed)
  await expect(page.getByText('Test Result')).toBeVisible();
  await expect(page.getByTestId('loading-spinner')).not.toBeVisible();
});

Where Cypress Is Still Better

Let's be fair. The Cypress dashboard and time-travel debugger are still nicer for debugging test failures locally. When a Cypress test fails, you get a visual replay of every command. Playwright has traces (which are excellent) but you have to open them separately in the trace viewer. It's a slightly more manual process.

Cypress also has Component Testing built in — testing React components in isolation inside a real browser. Playwright has experimental component testing but it's not at parity yet. If you're doing a lot of component-level E2E testing, Cypress is still ahead there. Though honestly, if you're testing components in isolation, Vitest + Testing Library is probably the right answer anyway.

  • Cypress Dashboard: nicer UI for CI result browsing if you pay for it
  • Time-travel debugger: easier to understand what happened step by step without digging into trace files
  • Component testing: more mature and stable than Playwright's equivalent
  • Ecosystem: more third-party plugins, though Playwright is catching up fast
  • Error messages: Cypress error messages are generally more beginner-friendly

Authentication Setup: A Real Advantage for Playwright

One thing that saves enormous time in Playwright is `storageState`. You log in once, save the browser state to a file, and every subsequent test that needs auth just loads that state. No logging in before every test. With Cypress, you're usually hitting your login endpoint with `cy.request()` to bypass the UI — which works but feels hacky and breaks if your auth flow changes.

// e2e/auth.setup.ts — run this once before your authenticated tests
import { test as setup, expect } from '@playwright/test';
import path from 'path';

const authFile = path.join(__dirname, '../.playwright/auth.json');

setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.getByTestId('email').fill(process.env.TEST_USER_EMAIL!);
  await page.getByTestId('password').fill(process.env.TEST_USER_PASSWORD!);
  await page.getByRole('button', { name: 'Sign in' }).click();
  await expect(page).toHaveURL(/dashboard/);

  // Save the auth state — cookies, localStorage, everything
  await page.context().storageState({ path: authFile });
});

// playwright.config.ts — use the saved state for authenticated tests
export default defineConfig({
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'authenticated',
      use: { storageState: '.playwright/auth.json' },
      dependencies: ['setup'],
    },
    {
      name: 'unauthenticated',
      testMatch: /.*public.*\.spec\.ts/,
    },
  ],
});

This pattern is a game-changer. Your CI runs the setup project once, saves the auth state, and all 50 authenticated tests share that state without hitting your login flow again. Faster tests, less load on your DB, and you're testing actual user sessions rather than mocked auth.

The Migration Reality Check

Migrating 80 tests took about a day and a half. Most of it was mechanical — converting `cy.get()` to `page.getByTestId()` or `page.getByRole()`. The tricky parts were tests that relied on Cypress-specific behavior like `.its()` or `.invoke()`, and tests that used Cypress intercept patterns differently. One piece of advice: don't try to do a 1:1 migration. Rewrite tests in idiomatic Playwright. Fighting to preserve Cypress patterns in Playwright will make you miserable.

Don't migrate your Cypress tests. Rewrite them. If you try to preserve Cypress idioms in Playwright, you'll get the worst of both worlds. Use the migration as an opportunity to clean up tests that were covering up bugs rather than catching them.

One thing we weren't prepared for: Playwright tests are stricter by default. Tests that were accidentally passing in Cypress because of loose assertions failed in Playwright because Playwright's element matching is more precise. That's actually good — we found two bugs in our UI that Cypress was silently ignoring. But it made the migration take longer than expected.

So Which One Should You Pick?

If you're starting a new project: Playwright. The parallel execution, smarter auto-waiting, real async/await API, and free CI parallelism make it the better default in 2025. The ecosystem has caught up and Microsoft actively maintains it.

If you have an existing Cypress suite that works: don't migrate unless you're feeling real pain. The grass is greener but it's not that much greener. Wait until you have a reason — a new project, a major refactor, or genuine suffering with flakiness in CI.

If your team is new to E2E testing: Playwright has better docs and the VS Code integration makes it less intimidating to get started. The codegen tool (`npx playwright codegen http://localhost:3000`) records browser interactions and generates test code, which is a great way to onboard developers who've never written E2E tests.

  • New project → Playwright, no debate
  • Existing Cypress suite, no major pain → stay put
  • CI is slow or flaky → migrate, the parallelism alone is worth it
  • Need component testing heavily → Cypress still has the edge
  • Testing Stripe Elements / iframes → Playwright handles this better
  • Small team, no Cypress Dashboard subscription → Playwright saves you money

The peal.dev templates ship with Playwright configured out of the box — the auth setup pattern, a reasonable config for Next.js, and a few example tests for common flows like login and checkout. It's one of those things where having it already wired up means you actually write tests instead of spending a day on configuration.

One last thing: whatever you pick, write the tests. An imperfect Cypress test suite is infinitely better than no tests. We've been bitten too many times by "we'll add tests later" — later never comes and 2am deploys get a lot scarier. Pick a tool and start with the three most important user flows. Auth, payment, and whatever makes your users money. Everything else is bonus.

Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders