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:
- Auto-waiting: No more manual waits or timeouts
- Better selectors: More reliable element targeting
- Built-in assertions: Cleaner, more readable tests
- 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:
- Performance matters: Faster tests encourage more testing
- Reliability is crucial: Flaky tests erode confidence
- Developer experience counts: Good tooling makes testing enjoyable
- 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