← Tech Guides
Test Suite Ready

Playwright & Cypress

End-to-end testing frameworks compared side-by-side

11 sections · Selectors, Assertions, CI, Debugging · 2026

01

Quick Reference

At-a-glance comparison of architecture, capabilities, and tradeoffs between the two dominant E2E testing frameworks.

Feature Playwright Cypress
Maintainer Microsoft Cypress.io (acquired by Atmosera)
Architecture CDP / BiDi protocol (out-of-process) Runs inside the browser (in-process)
Languages TypeScript, JavaScript, Python, Java, C# JavaScript / TypeScript only
Browsers Chromium, Firefox, WebKit (Safari) Chrome, Edge, Firefox, Electron
Parallelism Built-in workers (free) Cypress Cloud (paid) or third-party
Auto-wait Yes (actionability checks) Yes (automatic retry-ability)
Multi-tab / Multi-origin Full support cy.origin() for multi-origin; no multi-tab
Network Interception route() API cy.intercept()
Test Runner UI HTML report, Trace Viewer Interactive runner with time-travel
Component Testing Experimental (via @playwright/experimental-ct-*) Built-in (React, Vue, Angular, Svelte)
API Testing request context (built-in) cy.request() (limited to same-origin by default)
Mobile Emulation Devices, geolocation, permissions Viewport only (no real device emulation)
License Apache 2.0 MIT
$ npx playwright test
  ✓ login.spec.ts — should authenticate with valid credentials (1.2s)
  ✓ login.spec.ts — should show error for invalid password (0.8s)
  ✓ dashboard.spec.ts — should display user profile (2.1s)
  ✗ dashboard.spec.ts — should load analytics chart (3.4s)
  - settings.spec.ts — should toggle dark mode (skipped)
3 passed · 1 failed · 1 skipped · 5 total (7.5s)
02

Installation & Setup

Get from zero to first test in under two minutes with either framework.

Project Initialization

Playwright
# Initialize with wizard
npm init playwright@latest

# Or manual install
npm install -D @playwright/test
npx playwright install

# Project structure created:
# playwright.config.ts
# tests/
#   example.spec.ts
# tests-examples/
Cypress
# Install Cypress
npm install -D cypress

# Open interactive launcher
npx cypress open

# Project structure created:
# cypress.config.ts
# cypress/
#   e2e/
#   fixtures/
#   support/

Configuration

Playwright
// playwright.config.ts
import { defineConfig, devices } from
  '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
    {
      name: 'mobile',
      use: { ...devices['iPhone 14'] },
    },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});
Cypress
// cypress.config.ts
import { defineConfig } from 'cypress';

export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',
    viewportWidth: 1280,
    viewportHeight: 720,
    retries: {
      runMode: 2,
      openMode: 0,
    },
    video: true,
    screenshotOnRunFailure: true,
    defaultCommandTimeout: 10000,
    setupNodeEvents(on, config) {
      // Register plugins here
    },
    specPattern:
      'cypress/e2e/**/*.cy.{js,ts}',
  },
  component: {
    devServer: {
      framework: 'react',
      bundler: 'vite',
    },
  },
});

First Test

Playwright
// tests/home.spec.ts
import { test, expect } from
  '@playwright/test';

test('homepage has title', async ({ page }) => {
  await page.goto('/');

  await expect(page)
    .toHaveTitle(/My App/);

  await expect(
    page.getByRole('heading', { name: 'Welcome' })
  ).toBeVisible();
});
Cypress
// cypress/e2e/home.cy.ts
describe('Homepage', () => {
  it('has title', () => {
    cy.visit('/');

    cy.title()
      .should('match', /My App/);

    cy.get('h1')
      .contains('Welcome')
      .should('be.visible');
  });
});

Running Tests

Playwright
# Run all tests
npx playwright test

# Run specific file
npx playwright test home.spec.ts

# Run in headed mode
npx playwright test --headed

# Run specific browser
npx playwright test --project=firefox

# Run with UI mode (interactive)
npx playwright test --ui

# Debug a single test
npx playwright test --debug home.spec.ts

# Show HTML report
npx playwright show-report
Cypress
# Open interactive runner
npx cypress open

# Run headless (CI mode)
npx cypress run

# Run specific spec
npx cypress run --spec "cypress/e2e/home.cy.ts"

# Run specific browser
npx cypress run --browser firefox

# Run with headed browser
npx cypress run --headed

# Run component tests
npx cypress run --component

# Record to Cypress Cloud
npx cypress run --record --key <key>
03

Selectors & Locators

How each framework finds elements. Playwright encourages user-facing locators; Cypress uses CSS selectors with chaining.

Core Selector Strategies

Strategy Playwright Cypress
By role page.getByRole('button', { name: 'Submit' }) cy.contains('button', 'Submit')
By text page.getByText('Hello world') cy.contains('Hello world')
By label page.getByLabel('Email') cy.get('label').contains('Email').find('input')
By placeholder page.getByPlaceholder('Search...') cy.get('[placeholder="Search..."]')
By test ID page.getByTestId('nav-menu') cy.get('[data-testid="nav-menu"]')
CSS selector page.locator('.btn-primary') cy.get('.btn-primary')
XPath page.locator('xpath=//div[@id="app"]') cy.xpath('//div[@id="app"]') (plugin)
Nth match page.getByRole('listitem').nth(2) cy.get('li').eq(2)
Filter / chain locator.filter({ hasText: 'Buy' }) cy.get('.card').filter(':contains("Buy")')

Advanced Locator Patterns

Playwright
// Chained locators
const row = page
  .getByRole('row')
  .filter({ hasText: 'John' });
await row
  .getByRole('button', { name: 'Edit' })
  .click();

// Locator within frame
const frame = page.frameLocator('#checkout');
await frame.getByLabel('Card number').fill('4242...');

// Shadow DOM — automatic piercing
await page.locator('my-component')
  .getByText('Inner content').click();

// Multiple elements
const items = page.getByRole('listitem');
await expect(items).toHaveCount(5);
const texts = await items.allTextContents();

// Waiting built-in to locators
// (auto-waits up to 30s by default)
await page.getByRole('alert').waitFor();
await page.getByText('Saved!').waitFor({
  state: 'visible',
  timeout: 5000,
});
Cypress
// Chained commands
cy.get('tr')
  .contains('John')
  .parent('tr')
  .find('button')
  .contains('Edit')
  .click();

// Iframe content (plugin required)
cy.iframe('#checkout')
  .find('[name="cardNumber"]')
  .type('4242...');

// Shadow DOM
cy.get('my-component')
  .shadow()
  .find('.inner-content')
  .click();

// Multiple elements
cy.get('li')
  .should('have.length', 5)
  .each(($el) => {
    cy.wrap($el)
      .should('be.visible');
  });

// Explicit waiting
cy.get('[role="alert"]', { timeout: 10000 })
  .should('exist');
cy.contains('Saved!', { timeout: 5000 })
  .should('be.visible');
Best Practice: Both frameworks recommend selecting elements by accessible roles, labels, and text content rather than CSS classes or IDs. This creates more resilient tests that mirror how users actually interact with your application.
04

Assertions

Playwright uses expect() with auto-retrying web-first assertions. Cypress uses chainable .should() with built-in retry.

Common Assertions

Playwright
// Visibility
await expect(locator).toBeVisible();
await expect(locator).toBeHidden();

// Text content
await expect(locator).toHaveText('Hello');
await expect(locator).toContainText('Hell');

// Input values
await expect(locator).toHaveValue('test@mail.com');
await expect(locator).toBeChecked();
await expect(locator).toBeDisabled();
await expect(locator).toBeEditable();

// Attributes & CSS
await expect(locator).toHaveAttribute('href', '/about');
await expect(locator).toHaveClass(/active/);
await expect(locator).toHaveCSS('color', 'rgb(0, 128, 0)');

// Count
await expect(locator).toHaveCount(3);

// Page-level
await expect(page).toHaveTitle(/Dashboard/);
await expect(page).toHaveURL(/\/dashboard/);

// Negation
await expect(locator).not.toBeVisible();

// Soft assertions (don't stop test)
await expect.soft(locator).toHaveText('A');
await expect.soft(locator).toHaveText('B');
Cypress
// Visibility
cy.get(sel).should('be.visible');
cy.get(sel).should('not.be.visible');

// Text content
cy.get(sel).should('have.text', 'Hello');
cy.get(sel).should('contain.text', 'Hell');

// Input values
cy.get(sel).should('have.value', 'test@mail.com');
cy.get(sel).should('be.checked');
cy.get(sel).should('be.disabled');
cy.get(sel).should('not.be.disabled');

// Attributes & CSS
cy.get(sel).should('have.attr', 'href', '/about');
cy.get(sel).should('have.class', 'active');
cy.get(sel).should('have.css', 'color',
  'rgb(0, 128, 0)');

// Count
cy.get(sel).should('have.length', 3);

// Page-level
cy.title().should('match', /Dashboard/);
cy.url().should('include', '/dashboard');

// Negation
cy.get(sel).should('not.exist');

// Chained assertions
cy.get(sel)
  .should('be.visible')
  .and('contain', 'Hello')
  .and('have.class', 'active');

Custom Assertions & Retry Logic

Playwright
// Polling assertion (custom condition)
await expect(async () => {
  const resp = await page.request.get('/api/status');
  expect(resp.status()).toBe(200);
  const body = await resp.json();
  expect(body.ready).toBe(true);
}).toPass({
  intervals: [1000, 2000, 5000],
  timeout: 30000,
});

// Custom matcher via expect.extend
import { expect as baseExpect } from '@playwright/test';

export const expect = baseExpect.extend({
  async toHaveToastMessage(page, expected) {
    const toast = page.locator('.toast');
    await toast.waitFor();
    const text = await toast.textContent();
    return {
      pass: text?.includes(expected) ?? false,
      message: () => `Expected toast "${expected}"`,
    };
  },
});
Cypress
// Custom retry with should callback
cy.get('.status')
  .should(($el) => {
    const text = $el.text();
    expect(text).to.match(/ready|complete/i);
  });

// Retry-able API polling
function waitForReady() {
  cy.request('/api/status').then((resp) => {
    if (resp.body.ready !== true) {
      cy.wait(1000);
      waitForReady(); // recursive retry
    }
  });
}

// Custom Chai assertion
Cypress.Commands.add('shouldHaveToast',
  (message) => {
    cy.get('.toast', { timeout: 10000 })
      .should('be.visible')
      .and('contain', message);
  }
);

// Custom command usage
cy.shouldHaveToast('Settings saved');
Auto-retry difference: Playwright's expect() web assertions auto-retry until timeout. Cypress .should() retries the entire command chain. Both eliminate the need for manual sleep() or wait() calls in most scenarios.
05

Page Object Model

Encapsulate page interactions into reusable classes or custom commands for maintainable, DRY test suites.

Page Object Pattern

Playwright
// pages/login.page.ts
import { type Page, type Locator } from
  '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitBtn: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.getByLabel('Email');
    this.passwordInput = page.getByLabel('Password');
    this.submitBtn = page.getByRole('button',
      { name: 'Sign in' });
    this.errorMessage = page.getByRole('alert');
  }

  async goto() {
    await this.page.goto('/login');
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitBtn.click();
  }
}
Cypress
// cypress/pages/login.page.ts
export class LoginPage {
  get emailInput() {
    return cy.get('[data-testid="email"]');
  }

  get passwordInput() {
    return cy.get('[data-testid="password"]');
  }

  get submitBtn() {
    return cy.contains('button', 'Sign in');
  }

  get errorMessage() {
    return cy.get('[role="alert"]');
  }

  goto() {
    cy.visit('/login');
  }

  login(email: string, password: string) {
    this.emailInput.type(email);
    this.passwordInput.type(password);
    this.submitBtn.click();
  }
}

Using Page Objects in Tests

Playwright
// tests/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login.page';

test.describe('Login', () => {
  let loginPage: LoginPage;

  test.beforeEach(async ({ page }) => {
    loginPage = new LoginPage(page);
    await loginPage.goto();
  });

  test('successful login', async ({ page }) => {
    await loginPage.login(
      'user@test.com',
      'secret123'
    );
    await expect(page).toHaveURL(/dashboard/);
  });

  test('shows error for bad creds',
    async () => {
    await loginPage.login('bad@test.com', 'wrong');
    await expect(loginPage.errorMessage)
      .toContainText('Invalid credentials');
  });
});
Cypress
// cypress/e2e/login.cy.ts
import { LoginPage } from '../pages/login.page';

describe('Login', () => {
  const loginPage = new LoginPage();

  beforeEach(() => {
    loginPage.goto();
  });

  it('successful login', () => {
    loginPage.login(
      'user@test.com',
      'secret123'
    );
    cy.url().should('include', '/dashboard');
  });

  it('shows error for bad creds', () => {
    loginPage.login('bad@test.com', 'wrong');
    loginPage.errorMessage
      .should('contain', 'Invalid credentials');
  });
});

// Alternative: Custom Commands (Cypress idiom)
// cypress/support/commands.ts
Cypress.Commands.add('login',
  (email, password) => {
  cy.visit('/login');
  cy.get('[data-testid="email"]').type(email);
  cy.get('[data-testid="password"]')
    .type(password);
  cy.contains('Sign in').click();
});

Fixtures & Test Data

Playwright
// fixtures/test-data.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/login.page';
import { DashboardPage } from
  '../pages/dashboard.page';

// Extend base test with page fixtures
type Fixtures = {
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
  authenticatedPage: DashboardPage;
};

export const test = base.extend<Fixtures>({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },
  dashboardPage: async ({ page }, use) => {
    await use(new DashboardPage(page));
  },
  authenticatedPage: async ({ page }, use) => {
    // Setup: login via API
    const ctx = page.context();
    await ctx.request.post('/api/login', {
      data: { email: 'test@test.com',
              password: 'secret' }
    });
    const dashboard = new DashboardPage(page);
    await dashboard.goto();
    await use(dashboard);
  },
});

export { expect } from '@playwright/test';
Cypress
// cypress/fixtures/users.json
{
  "admin": {
    "email": "admin@test.com",
    "password": "admin123"
  },
  "user": {
    "email": "user@test.com",
    "password": "user123"
  }
}

// Usage in test
describe('Dashboard', () => {
  beforeEach(() => {
    cy.fixture('users').then((users) => {
      cy.login(users.admin.email,
               users.admin.password);
    });
  });

  it('shows admin panel', () => {
    cy.get('[data-testid="admin-panel"]')
      .should('be.visible');
  });
});

// Or use cy.fixture() shorthand
beforeEach(function () {
  cy.fixture('users').as('users');
});

it('uses aliased fixture', function () {
  cy.login(
    this.users.admin.email,
    this.users.admin.password
  );
});
06

API Testing

Test REST APIs directly without a browser. Playwright's request context supports full HTTP; Cypress uses cy.request().

Basic API Requests

Playwright
// API testing — no browser needed
import { test, expect } from '@playwright/test';

test('GET /api/users', async ({ request }) => {
  const response = await request.get('/api/users');

  expect(response.ok()).toBeTruthy();
  expect(response.status()).toBe(200);

  const users = await response.json();
  expect(users).toHaveLength(10);
  expect(users[0]).toHaveProperty('email');
});

test('POST /api/users', async ({ request }) => {
  const response = await request.post('/api/users', {
    data: {
      name: 'Jane Doe',
      email: 'jane@test.com',
    },
    headers: {
      Authorization: 'Bearer token123',
    },
  });

  expect(response.status()).toBe(201);
  const user = await response.json();
  expect(user.name).toBe('Jane Doe');
});

test('full CRUD lifecycle', async ({ request }) => {
  // Create
  const create = await request.post('/api/items', {
    data: { title: 'Test Item' },
  });
  const { id } = await create.json();

  // Read
  const get = await request.get(`/api/items/${id}`);
  expect(get.ok()).toBeTruthy();

  // Update
  const put = await request.put(`/api/items/${id}`, {
    data: { title: 'Updated Item' },
  });
  expect(put.ok()).toBeTruthy();

  // Delete
  const del = await request.delete(`/api/items/${id}`);
  expect(del.status()).toBe(204);
});
Cypress
// API testing with cy.request()
describe('Users API', () => {
  it('GET /api/users', () => {
    cy.request('GET', '/api/users')
      .then((response) => {
        expect(response.status).to.eq(200);
        expect(response.body).to.have.length(10);
        expect(response.body[0])
          .to.have.property('email');
      });
  });

  it('POST /api/users', () => {
    cy.request({
      method: 'POST',
      url: '/api/users',
      body: {
        name: 'Jane Doe',
        email: 'jane@test.com',
      },
      headers: {
        Authorization: 'Bearer token123',
      },
    }).then((response) => {
      expect(response.status).to.eq(201);
      expect(response.body.name)
        .to.eq('Jane Doe');
    });
  });

  it('full CRUD lifecycle', () => {
    // Create
    cy.request('POST', '/api/items', {
      title: 'Test Item',
    }).then((resp) => {
      const id = resp.body.id;

      // Read
      cy.request(`/api/items/${id}`)
        .its('status').should('eq', 200);

      // Update
      cy.request('PUT', `/api/items/${id}`, {
        title: 'Updated Item',
      }).its('status').should('eq', 200);

      // Delete
      cy.request('DELETE', `/api/items/${id}`)
        .its('status').should('eq', 204);
    });
  });
});

API Auth & Session Reuse

Playwright
// global-setup.ts — save auth state
import { chromium } from '@playwright/test';

async function globalSetup() {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.goto('/login');
  await page.getByLabel('Email')
    .fill('admin@test.com');
  await page.getByLabel('Password')
    .fill('secret');
  await page.getByRole('button',
    { name: 'Sign in' }).click();
  await page.waitForURL('/dashboard');

  // Save auth cookies/localStorage
  await page.context().storageState({
    path: '.auth/admin.json',
  });
  await browser.close();
}
export default globalSetup;

// playwright.config.ts
export default defineConfig({
  globalSetup: require.resolve('./global-setup'),
  projects: [{
    name: 'authenticated',
    use: {
      storageState: '.auth/admin.json',
    },
  }],
});
Cypress
// cypress/support/commands.ts
Cypress.Commands.add('loginByApi',
  (email, password) => {
  cy.session([email, password], () => {
    cy.request({
      method: 'POST',
      url: '/api/auth/login',
      body: { email, password },
    }).then(({ body }) => {
      window.localStorage.setItem(
        'authToken',
        body.token
      );
    });
  });
});

// Use in tests — session is cached
describe('Dashboard', () => {
  beforeEach(() => {
    cy.loginByApi(
      'admin@test.com',
      'secret'
    );
    cy.visit('/dashboard');
  });

  it('shows admin content', () => {
    cy.get('[data-testid="admin-panel"]')
      .should('exist');
  });
});

// cy.session() caches across specs
// in the same run, avoiding redundant logins
07

Network Interception

Mock, modify, and monitor network requests. Essential for isolating frontend tests from flaky backends.

Mocking API Responses

Playwright
// Mock a GET endpoint
await page.route('**/api/users', (route) => {
  route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify([
      { id: 1, name: 'Alice' },
      { id: 2, name: 'Bob' },
    ]),
  });
});

// Mock with file
await page.route('**/api/config', (route) => {
  route.fulfill({
    path: './mocks/config.json',
  });
});

// Modify real response
await page.route('**/api/users', async (route) => {
  const response = await route.fetch();
  const json = await response.json();
  // Add a test user to real data
  json.push({ id: 99, name: 'Test User' });
  await route.fulfill({
    response,
    body: JSON.stringify(json),
  });
});

// Abort specific requests
await page.route('**/*.{png,jpg}', (route) => {
  route.abort();
});

// Wait for specific request
const reqPromise = page.waitForRequest(
  '**/api/submit'
);
await page.getByRole('button',
  { name: 'Submit' }).click();
const request = await reqPromise;
expect(request.method()).toBe('POST');
Cypress
// Mock a GET endpoint
cy.intercept('GET', '/api/users', {
  statusCode: 200,
  body: [
    { id: 1, name: 'Alice' },
    { id: 2, name: 'Bob' },
  ],
}).as('getUsers');

// Mock with fixture file
cy.intercept('GET', '/api/config', {
  fixture: 'config.json',
}).as('getConfig');

// Modify real response
cy.intercept('GET', '/api/users', (req) => {
  req.continue((res) => {
    res.body.push(
      { id: 99, name: 'Test User' }
    );
  });
}).as('getUsers');

// Force network error
cy.intercept('POST', '/api/submit', {
  forceNetworkError: true,
}).as('submitError');

// Wait for aliased request
cy.get('button').contains('Submit').click();
cy.wait('@submitError');

// Assert on request body
cy.intercept('POST', '/api/submit').as('submit');
cy.get('form').submit();
cy.wait('@submit').then((interception) => {
  expect(interception.request.body)
    .to.have.property('name', 'Alice');
  expect(interception.response.statusCode)
    .to.eq(201);
});

Advanced Network Patterns

Playwright
// Record and replay (HAR)
// Record
await page.routeFromHAR('./recordings/api.har', {
  url: '**/api/**',
  update: true,
});
await page.goto('/');
// ... perform actions
await page.close(); // saves HAR

// Replay from HAR
await page.routeFromHAR('./recordings/api.har', {
  url: '**/api/**',
  update: false, // replay mode
});

// Simulate slow network
await page.route('**/api/**', async (route) => {
  await new Promise(r => setTimeout(r, 3000));
  await route.continue();
});

// WebSocket interception
page.on('websocket', (ws) => {
  ws.on('framereceived', (event) => {
    console.log('WS received:', event.payload);
  });
  ws.on('framesent', (event) => {
    console.log('WS sent:', event.payload);
  });
});
Cypress
// Simulate delay
cy.intercept('GET', '/api/data', (req) => {
  req.on('response', (res) => {
    res.setDelay(3000);
  });
}).as('slowData');

// Simulate throttled/flaky network
cy.intercept('GET', '/api/data', (req) => {
  req.on('response', (res) => {
    res.setThrottle(1000); // 1kb/s
  });
});

// Dynamic response based on request
cy.intercept('GET', '/api/search*', (req) => {
  const query = new URL(req.url)
    .searchParams.get('q');
  req.reply({
    results: query === 'empty'
      ? []
      : [{ id: 1, title: `Result: ${query}` }],
  });
});

// Spy without modifying
cy.intercept('GET', '/api/analytics').as('track');
cy.visit('/');
cy.wait('@track')
  .its('request.url')
  .should('include', 'page=home');

// Count number of requests
let apiCalls = 0;
cy.intercept('/api/**', () => { apiCalls++; });
// ... test actions
cy.then(() => {
  expect(apiCalls).to.be.greaterThan(0);
});
08

Visual Testing

Catch visual regressions with screenshot comparisons. Playwright has built-in support; Cypress requires plugins.

Screenshot Comparison

Playwright
// Full page screenshot comparison
await expect(page).toHaveScreenshot(
  'homepage.png'
);

// Element-level screenshot
await expect(
  page.getByTestId('hero-section')
).toHaveScreenshot('hero.png');

// With options
await expect(page).toHaveScreenshot(
  'dashboard.png',
  {
    maxDiffPixels: 100,
    maxDiffPixelRatio: 0.01,
    threshold: 0.2,
    animations: 'disabled',
    mask: [
      page.locator('.timestamp'),
      page.locator('.ad-banner'),
    ],
  }
);

// Update snapshots (first run or reset)
// npx playwright test --update-snapshots

// Full-page screenshot (scrolls entire page)
await expect(page).toHaveScreenshot({
  fullPage: true,
});

// Screenshots are stored per-project:
// tests/home.spec.ts-snapshots/
//   homepage-chromium-linux.png
//   homepage-firefox-linux.png
//   homepage-webkit-linux.png
Cypress
// Using cypress-image-snapshot plugin

// cypress/support/e2e.ts
import { addMatchImageSnapshotCommand }
  from '@simonsmith/cypress-image-snapshot/command';
addMatchImageSnapshotCommand();

// Full page snapshot
cy.matchImageSnapshot('homepage');

// Element snapshot
cy.get('[data-testid="hero-section"]')
  .matchImageSnapshot('hero');

// With options
cy.matchImageSnapshot('dashboard', {
  failureThreshold: 0.01,
  failureThresholdType: 'percent',
  customDiffConfig: { threshold: 0.2 },
  blackout: [
    '.timestamp',
    '.ad-banner',
  ],
});

// Update snapshots
// npx cypress run --env updateSnapshots=true

// Alternative: Percy (cloud-based)
// npm install @percy/cypress
cy.percySnapshot('Homepage');
cy.percySnapshot('Dashboard', {
  widths: [375, 768, 1280],
});

// Alternative: Applitools Eyes
// npm install @applitools/eyes-cypress
cy.eyesOpen({ appName: 'My App' });
cy.eyesCheckWindow('Homepage');
cy.eyesClose();
Screenshot gotchas: Visual tests are sensitive to OS, font rendering, and browser version. Playwright stores platform-specific baselines (e.g., chromium-linux.png). Always run screenshot updates in CI for consistent baselines across the team.
09

Component Testing

Mount and test individual UI components in isolation without a full application server.

React Component Testing

Playwright
// Experimental component testing
// npm install @playwright/experimental-ct-react

// Button.spec.tsx
import { test, expect } from
  '@playwright/experimental-ct-react';
import { Button } from './Button';

test('renders with label', async ({ mount }) => {
  const component = await mount(
    <Button label="Click me" />
  );
  await expect(component)
    .toContainText('Click me');
});

test('fires onClick', async ({ mount }) => {
  let clicked = false;
  const component = await mount(
    <Button
      label="Submit"
      onClick={() => { clicked = true; }}
    />
  );
  await component.click();
  expect(clicked).toBeTruthy();
});

test('disabled state', async ({ mount }) => {
  const component = await mount(
    <Button label="Save" disabled />
  );
  await expect(component).toBeDisabled();
  await expect(component)
    .toHaveCSS('opacity', '0.5');
});
Cypress
// Built-in component testing
// cypress.config.ts → component.devServer

// Button.cy.tsx
import Button from './Button';

describe('Button', () => {
  it('renders with label', () => {
    cy.mount(<Button label="Click me" />);
    cy.contains('Click me')
      .should('be.visible');
  });

  it('fires onClick', () => {
    const onClick = cy.stub().as('click');
    cy.mount(
      <Button
        label="Submit"
        onClick={onClick}
      />
    );
    cy.contains('Submit').click();
    cy.get('@click')
      .should('have.been.calledOnce');
  });

  it('disabled state', () => {
    cy.mount(
      <Button label="Save" disabled />
    );
    cy.get('button')
      .should('be.disabled')
      .and('have.css', 'opacity', '0.5');
  });
});

// Supported frameworks:
// React, Vue, Angular, Svelte
// Each has its own mount adapter

Component Test Configuration

Playwright
// playwright-ct.config.ts
import { defineConfig } from
  '@playwright/experimental-ct-react';

export default defineConfig({
  testDir: './src',
  testMatch: '**/*.spec.tsx',
  use: {
    ctPort: 3100,
    ctViteConfig: {
      // Vite config overrides
      resolve: {
        alias: { '@': './src' },
      },
    },
  },
});

// Run component tests
// npx playwright test -c playwright-ct.config.ts
Cypress
// cypress.config.ts
import { defineConfig } from 'cypress';

export default defineConfig({
  component: {
    devServer: {
      framework: 'react',     // or vue, angular
      bundler: 'vite',        // or webpack
    },
    specPattern: 'src/**/*.cy.{ts,tsx}',
    supportFile: 'cypress/support/component.ts',
    indexHtmlFile:
      'cypress/support/component-index.html',
  },
});

// cypress/support/component.ts
import { mount } from 'cypress/react18';
Cypress.Commands.add('mount', mount);

// Run component tests
// npx cypress run --component
Cypress leads here: Cypress component testing is production-ready and supports React, Vue, Angular, and Svelte out of the box. Playwright's component testing is still experimental but uses real browsers (not jsdom), giving higher-fidelity results.
10

CI/CD & Parallel Execution

Run tests in GitHub Actions with parallelism, artifacts, and optimized caching.

GitHub Actions Workflow

Playwright
# .github/workflows/playwright.yml
name: Playwright Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - run: npm ci

      - name: Install browsers
        run: npx playwright install
          --with-deps

      - name: Run tests
        run: npx playwright test

      - name: Upload report
        uses: actions/upload-artifact@v4
        if: ${{ !cancelled() }}
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30
Cypress
# .github/workflows/cypress.yml
name: Cypress Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - run: npm ci

      - name: Cypress run
        uses: cypress-io/github-action@v6
        with:
          build: npm run build
          start: npm run preview
          wait-on: http://localhost:4173

      - name: Upload screenshots
        uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: cypress-screenshots
          path: cypress/screenshots/
          retention-days: 7

Parallel Execution

Playwright
// playwright.config.ts
export default defineConfig({
  // Runs test files in parallel
  fullyParallel: true,

  // Number of parallel workers
  // Defaults to 1/2 of CPU cores
  workers: process.env.CI ? 4 : undefined,

  // Shard across CI machines
  // npx playwright test --shard=1/4
  // npx playwright test --shard=2/4
  // npx playwright test --shard=3/4
  // npx playwright test --shard=4/4
});

// GitHub Actions matrix sharding
// jobs:
//   test:
//     strategy:
//       matrix:
//         shard: [1/4, 2/4, 3/4, 4/4]
//     steps:
//       - run: npx playwright test
//               --shard=${{ matrix.shard }}

// Merge sharded reports
// npx playwright merge-reports
//   --reporter html ./all-blob-reports
Cypress
// Option 1: Cypress Cloud (paid)
// npx cypress run --record
//   --parallel --key <record-key>
// Automatically balances specs across
// multiple CI machines

// Option 2: sorry-cypress (OSS)
// Self-hosted parallelization director
// CYPRESS_API_URL=https://your-sorry-cypress
// npx cypress run --record --key any
//   --parallel

// Option 3: Manual sharding
// Split spec files across CI jobs
// jobs:
//   test:
//     strategy:
//       matrix:
//         spec: [
//           "cypress/e2e/auth/**",
//           "cypress/e2e/dashboard/**",
//           "cypress/e2e/settings/**",
//         ]
//     steps:
//       - run: npx cypress run
//               --spec "${{ matrix.spec }}"

// Option 4: cypress-split plugin
// npm install cypress-split
// npx cypress run
//   --env split=true,splitIndex=0,splitTotal=3

Docker Support

Playwright
# Official Docker image
FROM mcr.microsoft.com/playwright:v1.50.0

WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .

RUN npx playwright test

# Pre-built images with all browsers:
# mcr.microsoft.com/playwright:v1.50.0-noble
# mcr.microsoft.com/playwright:v1.50.0-jammy
Cypress
# Official Docker images
FROM cypress/included:13.17.0

WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .

RUN cypress run

# Image variants:
# cypress/base     — Node + deps (no browser)
# cypress/browsers — Node + Chrome + Firefox
# cypress/included — Node + Cypress + browsers
Playwright advantage: Built-in free parallelism with --shard and fullyParallel makes scaling straightforward. Cypress parallelism requires either their paid Cloud service or a third-party solution.
11

Debugging

Both frameworks provide powerful debugging tools. Playwright offers Trace Viewer and Codegen; Cypress offers time-travel and interactive runner.

Interactive Debugging

Playwright
// Pause execution and inspect
await page.pause();
// Opens Playwright Inspector:
//   - Step over each action
//   - Pick locators visually
//   - See live element highlighting

// Debug mode (auto-pauses)
// PWDEBUG=1 npx playwright test

// Slow down execution
// npx playwright test --headed
//   --slow-mo=500

// Generate test code by recording
// npx playwright codegen localhost:3000
// (Records your actions as test code)

// Trace Viewer — post-mortem debugging
// playwright.config.ts:
// use: { trace: 'on' }
//
// npx playwright show-trace
//   test-results/trace.zip
//
// Trace includes:
//   - DOM snapshots at each step
//   - Network requests timeline
//   - Console logs
//   - Action log with timing

// VS Code Extension
// Install "Playwright Test for VS Code"
// - Run/debug from editor gutter
// - Pick locators from live browser
// - View trace inline
Cypress
// Interactive Test Runner
// npx cypress open
// Features:
//   - Time-travel: click any step to see
//     DOM snapshot at that point
//   - Before/after state toggling
//   - Selector playground
//   - Console output per command

// Pause execution
cy.pause();
// Pauses at this point in the runner
// Resume with "Play" button

// Debug command
cy.get('.item').debug();
// Logs subject to console for inspection

// Slow down commands
// cypress.config.ts:
// defaultCommandTimeout: 30000

// Console debugging
cy.get('.item').then(($el) => {
  console.log('Element:', $el);
  debugger; // Chrome DevTools breakpoint
});

// cy.log() for runner output
cy.log('Checking user state...');
cy.get('.user').should('exist');

// Videos (enabled by default in run mode)
// cypress.config.ts: video: true
// Saved to cypress/videos/

// Screenshots on failure
// Automatic in run mode
// Manual: cy.screenshot('my-screenshot');

Debugging Toolkit Comparison

Trace Viewer PW

Post-mortem analysis with DOM snapshots at every step, network timeline, console logs, and action playback. Open traces from CI artifacts without re-running tests.

Time Travel CY

Click any command in the test runner to see the DOM at that exact moment. Toggle between before/after states. Inspect elements directly with browser DevTools.

Codegen PW

Record user interactions and generate test code automatically. Supports all browsers and outputs TypeScript/JavaScript/Python/Java/C#.

Cypress Studio CY

Record new commands directly in the test runner and insert them into existing tests. Experimental feature for quick test authoring.

UI Mode PW

Interactive watch mode with live test execution, DOM exploration, and locator picking. The closest Playwright equivalent to Cypress's interactive runner.

Selector Playground CY

Built into the test runner. Hover over elements to see selectors. Copy commands directly. Validates uniqueness of CSS selectors in real time.

Common Debugging Scenarios

Problem Playwright Cypress
Flaky test npx playwright test --repeat-each=10 Cypress._.times(10, () => { it(...) })
Element not found Trace Viewer shows DOM at failure point Time-travel to the failing command
Wrong element clicked await page.pause() + Inspector highlighting cy.pause() + Selector Playground
Network issue Trace Viewer network tab (HAR-like) Network requests in test runner sidebar
Timeout test.setTimeout(60000) per test { timeout: 60000 } per command
CI-only failure Download trace artifact, open locally Download video + screenshots from CI

Test Hooks & Lifecycle

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

// Runs once before all tests in file
test.beforeAll(async () => {
  // seed database, start services
});

// Runs before each test
test.beforeEach(async ({ page }) => {
  await page.goto('/');
});

// Runs after each test
test.afterEach(async ({ page }, testInfo) => {
  if (testInfo.status !== 'passed') {
    await page.screenshot({
      path: `failures/${testInfo.title}.png`,
    });
  }
});

// Runs once after all tests in file
test.afterAll(async () => {
  // cleanup
});

// Test annotations
test('feature @smoke', async ({ page }) => {});
test.skip('not ready yet', async () => {});
test.fixme('known bug #123', async () => {});
test.slow(); // triples the timeout

// Conditional skip
test('windows only', async ({ page }) => {
  test.skip(
    process.platform !== 'win32',
    'Windows only'
  );
});
Cypress
describe('Feature', () => {
  // Runs once before all tests
  before(() => {
    // seed database, start services
  });

  // Runs before each test
  beforeEach(() => {
    cy.visit('/');
  });

  // Runs after each test
  afterEach(function () {
    if (this.currentTest?.state === 'failed') {
      // Cypress auto-screenshots on failure
      // in run mode
    }
  });

  // Runs once after all tests
  after(() => {
    // cleanup
  });

  // Skip test
  it.skip('not ready yet', () => {});

  // Only run this test
  it.only('focus this test', () => {});

  // Conditional skip
  it('desktop only', function () {
    if (Cypress.config('viewportWidth') < 768) {
      this.skip();
    }
    // ... desktop-only assertions
  });

  // Tags (via grep plugin)
  // npm install @cypress/grep
  it('smoke test', { tags: '@smoke' }, () => {
    // npx cypress run --env grepTags=@smoke
  });
});
++

Which Framework Should You Choose?

Choose Playwright if...

  • You need multi-browser (WebKit/Safari) testing
  • You test across multiple languages (Python, Java, C#)
  • You need multi-tab or multi-origin support
  • Free parallelism and sharding matter
  • You want API testing built-in
  • Mobile device emulation is important
  • You prefer async/await over chaining

Choose Cypress if...

  • Developer experience is your top priority
  • You want the best interactive debugging UI
  • You need production-ready component testing
  • Your team is JavaScript/TypeScript only
  • You value extensive plugin ecosystem
  • Time-travel debugging is important to you
  • You prefer chainable assertion syntax
Decision Matrix
==============
  Cross-browser coverage      Playwright > Cypress
  Multi-language support      Playwright > Cypress
  Interactive debugging       Cypress > Playwright
  Component testing          Cypress > Playwright
  Free parallelism           Playwright > Cypress
  Plugin ecosystem           Cypress > Playwright
  Multi-tab / multi-origin    Playwright > Cypress
  API testing                Playwright ≈ Cypress
  CI/CD integration          Playwright ≈ Cypress
Both are excellent. The trend since 2023 has been toward Playwright for new projects, while Cypress remains strong for teams already invested in its ecosystem.