← Back to Blog Our Take

Ditching Cypress for Playwright: The Speed and Stability I Needed

April 2, 2025
Ditching Cypress for Playwright: The Speed and Stability I Needed

After two years of wrestling with flaky Cypress tests that would randomly fail in CI, take forever to run, and leave me debugging mysterious timeouts, I made the switch to Playwright. Six months later, our test suite runs 3x faster, fails 90% less often, and actually helps catch bugs instead of creating them. Here’s why I made the switch and what I learned.

The Cypress Frustration Point

Our Cypress test suite had grown to over 200 end-to-end tests, and what started as a helpful safety net had become a development bottleneck:

// Typical flaky Cypress test
describe('User Dashboard', () => {
  it('should load user data', () => {
    cy.visit('/dashboard')
    
    // This would randomly fail
    cy.get('[data-cy="user-name"]', { timeout: 10000 })
      .should('be.visible')
      .and('contain', 'John Doe')
    
    // Random network timing issues
    cy.get('[data-cy="stats-card"]').should('have.length', 4)
    cy.wait(2000) // Arbitrary waits everywhere
    
    // Would fail if animations were still running
    cy.get('[data-cy="chart-container"]')
      .should('be.visible')
      .click()
  })
})

The problems were mounting:

  • Flaky tests: 15-20% failure rate in CI
  • Slow execution: 45 minutes for full suite
  • Debugging nightmares: Screenshots didn’t capture the real issue
  • Browser limitations: Stuck with Chrome/Electron
  • Limited parallelization: Expensive CI minutes

Enter Playwright: A Different Philosophy

Playwright approached testing differently from the ground up:

// Equivalent Playwright test
import { test, expect } from '@playwright/test'

test('user dashboard loads correctly', async ({ page }) => {
  await page.goto('/dashboard')
  
  // Auto-waiting built in
  await expect(page.locator('[data-testid="user-name"]'))
    .toHaveText('John Doe')
  
  // Wait for network to be idle
  await page.waitForLoadState('networkidle')
  
  // Reliable element interaction
  const statsCards = page.locator('[data-testid="stats-card"]')
  await expect(statsCards).toHaveCount(4)
  
  // Built-in actionability checks
  await page.locator('[data-testid="chart-container"]').click()
})

The differences were immediately apparent:

  1. Auto-waiting: No more manual waits or timeouts
  2. Better selectors: More reliable element targeting
  3. Built-in assertions: Cleaner, more readable tests
  4. Multi-browser: Chrome, Firefox, Safari, Edge support

Performance: The Numbers Don’t Lie

Here’s a real comparison from our test migration:

Test Execution Time

# Cypress (Chrome only)
$ npm run test:e2e:cypress
 Dashboard Tests (4 failed, 12 passed) - 8m 32s
 Auth Flow Tests (2 failed, 8 passed) - 3m 45s
 Shopping Cart Tests (1 failed, 6 passed) - 5m 12s
 Admin Panel Tests (3 failed, 15 passed) - 12m 18s

Total: 29m 47s (10 failed, 41 passed)

# Playwright (all browsers in parallel)
$ npm run test:e2e:playwright
 Dashboard Tests (16 passed) - 2m 12s
 Auth Flow Tests (10 passed) - 1m 28s  
 Shopping Cart Tests (7 passed) - 1m 45s
 Admin Panel Tests (18 passed) - 3m 34s

Total: 8m 59s (51 passed, 0 failed)

Resource Usage

# CI Resource Comparison
Cypress:
  - CPU Usage: High (single process bottleneck)
  - Memory: 2.1GB peak
  - Network: Multiple redundant requests
  - Parallelization: Limited (expensive)

Playwright:
  - CPU Usage: Distributed across cores
  - Memory: 1.3GB peak
  - Network: Smart request interception
  - Parallelization: Built-in (free)

Real-World Migration Examples

1. Authentication Flow Testing

Before (Cypress):

describe('Authentication', () => {
  beforeEach(() => {
    cy.visit('/login')
    cy.clearCookies()
    cy.clearLocalStorage()
  })

  it('should handle login flow', () => {
    // Flaky form filling
    cy.get('#email').type('user@example.com')
    cy.get('#password').type('password123')
    cy.get('[data-cy="login-btn"]').click()
    
    // Unreliable redirect detection
    cy.url().should('include', '/dashboard')
    cy.wait(3000) // Wait for page to "settle"
    
    // Would fail if loading spinner still visible
    cy.get('[data-cy="user-menu"]').should('be.visible')
  })

  it('should handle invalid credentials', () => {
    cy.get('#email').type('invalid@example.com')
    cy.get('#password').type('wrongpassword')
    cy.get('[data-cy="login-btn"]').click()
    
    // Timing issues with error messages
    cy.get('[data-cy="error-message"]')
      .should('be.visible')
      .and('contain', 'Invalid credentials')
  })
})

After (Playwright):

import { test, expect } from '@playwright/test'

test.describe('Authentication', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/login')
  })

  test('should handle login flow', async ({ page }) => {
    // Fill form reliably
    await page.fill('#email', 'user@example.com')
    await page.fill('#password', 'password123')
    
    // Wait for navigation automatically
    await Promise.all([
      page.waitForNavigation(),
      page.click('[data-testid="login-btn"]')
    ])
    
    // Smart waiting for elements
    await expect(page).toHaveURL(/.*dashboard/)
    await expect(page.locator('[data-testid="user-menu"]'))
      .toBeVisible()
  })

  test('should handle invalid credentials', async ({ page }) => {
    await page.fill('#email', 'invalid@example.com')
    await page.fill('#password', 'wrongpassword')
    await page.click('[data-testid="login-btn"]')
    
    // Auto-waits for error message
    await expect(page.locator('[data-testid="error-message"]'))
      .toHaveText('Invalid credentials')
  })
})

2. API Integration Testing

Playwright’s Network Interception:

test('handles API failures gracefully', async ({ page }) => {
  // Mock API responses
  await page.route('/api/user/profile', route => {
    route.fulfill({
      status: 500,
      contentType: 'application/json',
      body: JSON.stringify({ error: 'Server error' })
    })
  })

  await page.goto('/dashboard')
  
  // Verify error handling
  await expect(page.locator('[data-testid="error-banner"]'))
    .toHaveText('Unable to load profile data')
  
  // Verify retry mechanism
  const retryBtn = page.locator('[data-testid="retry-btn"]')
  await expect(retryBtn).toBeVisible()
  
  // Mock successful retry
  await page.route('/api/user/profile', route => {
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ 
        name: 'John Doe', 
        email: 'john@example.com' 
      })
    })
  })
  
  await retryBtn.click()
  
  await expect(page.locator('[data-testid="user-name"]'))
    .toHaveText('John Doe')
})

3. Complex User Interactions

Shopping Cart Flow:

test('complete checkout flow', async ({ page, context }) => {
  // Start with authenticated user
  await context.addCookies([{
    name: 'auth-token',
    value: 'valid-token',
    domain: 'localhost',
    path: '/'
  }])

  await page.goto('/products')
  
  // Add multiple items to cart
  const products = page.locator('[data-testid="product-card"]')
  for (let i = 0; i < 3; i++) {
    await products.nth(i).locator('[data-testid="add-to-cart"]').click()
    
    // Wait for cart update animation
    await expect(page.locator('[data-testid="cart-count"]'))
      .toHaveText((i + 1).toString())
  }
  
  // Proceed to checkout
  await page.click('[data-testid="cart-icon"]')
  await page.click('[data-testid="checkout-btn"]')
  
  // Fill shipping form with auto-wait
  await page.fill('[data-testid="address"]', '123 Main St')
  await page.fill('[data-testid="city"]', 'Anytown')
  await page.selectOption('[data-testid="state"]', 'CA')
  await page.fill('[data-testid="zip"]', '12345')
  
  // Handle payment form in iframe
  const paymentFrame = page.frameLocator('[data-testid="payment-iframe"]')
  await paymentFrame.fill('[data-testid="card-number"]', '4242424242424242')
  await paymentFrame.fill('[data-testid="expiry"]', '12/25')
  await paymentFrame.fill('[data-testid="cvc"]', '123')
  
  // Complete purchase
  await Promise.all([
    page.waitForURL('/order-confirmation'),
    page.click('[data-testid="complete-order"]')
  ])
  
  // Verify success
  await expect(page.locator('[data-testid="order-number"]'))
    .toBeVisible()
    
  const orderNumber = await page.locator('[data-testid="order-number"]')
    .textContent()
  expect(orderNumber).toMatch(/^ORD-\d{8}$/)
})

Advanced Playwright Features

1. Visual Regression Testing

test('visual regression tests', async ({ page }) => {
  await page.goto('/dashboard')
  
  // Wait for dynamic content
  await page.waitForLoadState('networkidle')
  
  // Full page screenshot
  await expect(page).toHaveScreenshot('dashboard-full.png')
  
  // Component-specific screenshots
  await expect(page.locator('[data-testid="stats-widget"]'))
    .toHaveScreenshot('stats-widget.png')
  
  // Mobile viewport comparison
  await page.setViewportSize({ width: 375, height: 667 })
  await expect(page).toHaveScreenshot('dashboard-mobile.png')
})

2. Performance Testing

test('page performance metrics', async ({ page }) => {
  // Start performance monitoring
  await page.goto('/dashboard')
  
  const metrics = await page.evaluate(() => {
    const navigation = performance.getEntriesByType('navigation')[0]
    return {
      domContentLoaded: navigation.domContentLoadedEventEnd - navigation.domContentLoadedEventStart,
      loadComplete: navigation.loadEventEnd - navigation.loadEventStart,
      firstPaint: performance.getEntriesByName('first-paint')[0]?.startTime,
      firstContentfulPaint: performance.getEntriesByName('first-contentful-paint')[0]?.startTime
    }
  })
  
  // Assert performance thresholds
  expect(metrics.domContentLoaded).toBeLessThan(2000)
  expect(metrics.firstContentfulPaint).toBeLessThan(1500)
})

3. Multi-Browser Testing

// playwright.config.js
module.exports = {
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
    {
      name: 'mobile-chrome',
      use: { ...devices['Pixel 5'] },
    },
    {
      name: 'mobile-safari',
      use: { ...devices['iPhone 12'] },
    },
  ],
}

// Cross-browser specific tests
test.describe('Cross-browser compatibility', () => {
  ['chromium', 'firefox', 'webkit'].forEach(browserName => {
    test(`works in ${browserName}`, async ({ page }) => {
      await page.goto('/app')
      await expect(page.locator('h1')).toBeVisible()
    })
  })
})

Debugging: Night and Day Difference

Cypress Debugging Experience

// Limited debugging info
cy.get('[data-cy="button"]').click()
// Test fails with: "Element not found" 
// Screenshot shows page loaded but element missing
// Video shows fast playback, hard to see timing issues
// Console logs mixed with Cypress internals

Playwright Debugging Experience

// Rich debugging capabilities
await page.pause() // Interactive debugger

// Trace viewer shows:
// - Exact DOM state at failure
// - Network requests with timing
// - Console logs clearly separated
// - Action screenshots before/after
// - Performance metrics

// Headed mode for development
test('debug mode', async ({ page }) => {
  await page.goto('/app')
  await page.screenshot({ path: 'debug.png' })
  await page.pause() // Opens interactive debugger
})

The Playwright trace viewer was a game-changer:

# Generate trace
npx playwright test --trace=on

# View detailed trace
npx playwright show-trace trace.zip

Configuration and CI Integration

Playwright Configuration

// playwright.config.js
module.exports = {
  testDir: './tests',
  timeout: 30000,
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: [
    ['html'],
    ['junit', { outputFile: 'test-results/junit.xml' }],
    process.env.CI ? ['github'] : ['line']
  ],
  use: {
    baseURL: process.env.BASE_URL || 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
  projects: [
    {
      name: 'setup',
      testMatch: /.*\.setup\.js/,
    },
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
      dependencies: ['setup'],
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
      dependencies: ['setup'],
    },
  ],
}

GitHub Actions Integration

# .github/workflows/e2e.yml
name: E2E Tests
on:
  pull_request:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Install Playwright
        run: npx playwright install --with-deps
      
      - name: Start application
        run: npm run build && npm run start &
      
      - name: Wait for app
        run: npx wait-on http://localhost:3000
      
      - name: Run Playwright tests
        run: npx playwright test
      
      - name: Upload test results
        uses: actions/upload-artifact@v3
        if: always()
        with:
          name: playwright-report
          path: playwright-report/
          
      - name: Upload trace files
        uses: actions/upload-artifact@v3
        if: failure()
        with:
          name: playwright-traces
          path: test-results/

Migration Strategy

If you’re considering the switch, here’s the approach that worked for us:

Phase 1: Parallel Implementation (2 weeks)

# Keep existing Cypress tests
npm run test:e2e:cypress

# Implement critical path tests in Playwright
npm run test:e2e:playwright:critical

Phase 2: Feature Parity (4 weeks)

# Gradually migrate test suites
tests/
├── cypress/           # Deprecated
   └── legacy/
├── playwright/        # New tests
   ├── auth/
   ├── dashboard/
   ├── checkout/
   └── admin/
└── migration-status.md

Phase 3: Full Migration (2 weeks)

# Remove Cypress completely
npm uninstall cypress
rm -rf cypress/
git rm .circleci/cypress-config.yml

# Update CI/CD pipelines
# Update documentation
# Train team on Playwright

The Results: 6 Months Later

Our testing metrics after the migration:

Before (Cypress):
  - Test execution time: 45 minutes
  - Flaky test rate: 18%
  - CI failure rate: 23%
  - Developer satisfaction: 3/10
  - Bugs caught in production: 12/month

After (Playwright):
  - Test execution time: 15 minutes
  - Flaky test rate: 2%
  - CI failure rate: 4%
  - Developer satisfaction: 9/10
  - Bugs caught in production: 3/month

When NOT to Switch

Playwright isn’t perfect for everyone:

  • Existing Cypress expertise: If your team is deeply skilled in Cypress
  • Simple test needs: Basic happy-path testing might not justify migration
  • Legacy browser support: If you need IE11 support
  • Cypress-specific plugins: Heavy investment in Cypress ecosystem

Conclusion

The switch from Cypress to Playwright transformed our testing experience. The improved speed, stability, and debugging capabilities made our tests an asset rather than a liability. While migration required effort, the productivity gains paid for themselves within months.

Key takeaways:

  1. Performance matters: Faster tests encourage more testing
  2. Reliability is crucial: Flaky tests erode confidence
  3. Developer experience counts: Good tooling makes testing enjoyable
  4. Multi-browser support is valuable: Catch browser-specific issues early

If you’re struggling with flaky, slow, or unreliable end-to-end tests, Playwright might be the solution you’ve been looking for. The initial investment in migration pays dividends in improved developer productivity and software quality.

The testing landscape has evolved, and it’s time our tools evolved with it.

Ready to Build Something Amazing?

Let's discuss how Aviron Labs can help bring your ideas to life with custom software solutions.

Get in Touch