Skip to main content

End-to-End Testing: Playwright vs Cypress Complete Comparison

Compare Playwright and Cypress for end-to-end testing. Learn features, performance, syntax, and when to choose each tool with practical examples and best practices.

Table of Contents


Introduction

End-to-end (E2E) testing is the final frontier of quality assurance, simulating real user interactions across your entire application. Unlike unit tests that verify individual functions or integration tests that check component interactions, E2E tests validate complete user workflows from start to finish. They catch bugs that only appear when all parts of your application work together—navigation issues, authentication flows, form submissions, and complex user journeys.

Choosing the right E2E testing tool is crucial for your testing strategy. Two of the most popular modern E2E testing frameworks are Playwright and Cypress. Both are powerful, feature-rich tools that have gained massive adoption in the JavaScript ecosystem, but they take different approaches to solving the same problem. Understanding their strengths, weaknesses, and ideal use cases will help you make an informed decision for your project.

Playwright, developed by Microsoft, is a cross-browser automation library that supports multiple browsers out of the box and provides excellent performance. Cypress, on the other hand, offers a unique developer experience with its time-travel debugging and built-in test runner. Each tool has passionate advocates and specific scenarios where it shines.

This comprehensive guide will help you understand Playwright and Cypress in depth. You’ll learn their core features, see practical examples of writing tests in both frameworks, understand their performance characteristics, and discover when each tool is the right choice. By the end, you’ll be equipped to choose the best E2E testing tool for your specific needs and write effective tests that catch bugs before they reach production.


What is End-to-End Testing?

End-to-end testing validates that your entire application works correctly from a user’s perspective. These tests simulate real user interactions—clicking buttons, filling forms, navigating between pages—and verify that the application behaves as expected. E2E tests run against a real or near-real environment, including the browser, network requests, and backend services.

Why E2E Testing Matters

E2E tests catch bugs that unit and integration tests miss:

  • Integration issues: Problems that only appear when multiple systems work together
  • User experience bugs: Navigation problems, broken links, UI inconsistencies
  • Browser-specific issues: Rendering problems, JavaScript errors in specific browsers
  • Authentication and authorization: Login flows, session management, protected routes
  • Data flow: Complete workflows from user input to database to UI updates

E2E Testing in the Testing Pyramid

E2E tests sit at the top of the testing pyramid, representing fewer but more comprehensive tests:

/\
/E2E\ ← Few tests, slow, expensive
/------\
/--------\
/Integration\ ← Some tests, medium speed
/------------\
/--------------\
/ Unit Tests \ ← Many tests, fast, cheap
/------------------\

💡 Key Insight: E2E tests should be strategic—focus on critical user journeys rather than trying to test every possible interaction. For comprehensive testing strategies, see our guide on testing strategies for modern web applications.


Playwright Overview

Playwright is a modern, open-source E2E testing framework developed by Microsoft. It was released in 2020 and quickly gained popularity for its speed, reliability, and cross-browser support. Playwright provides a unified API for automating Chromium, Firefox, and WebKit browsers.

Key Characteristics

Architecture: Playwright uses a client-server architecture where tests communicate with browser instances through a protocol. This allows for better isolation and parallelization.

Language Support: While Playwright supports multiple languages (JavaScript, TypeScript, Python, Java, C#), it’s most commonly used with JavaScript/TypeScript in Node.js environments.

Browser Automation: Playwright controls browsers programmatically, allowing for precise control over browser behavior, network conditions, and browser contexts.

Core Strengths

Multi-browser support: Chromium, Firefox, and WebKit out of the box
Fast execution: Parallel test execution and efficient browser management
Reliable: Auto-waiting, network interception, and robust selectors
Powerful features: Screenshots, videos, network mocking, mobile emulation
Modern API: Async/await support, TypeScript-first design


Cypress Overview

Cypress is a JavaScript-based E2E testing framework that runs directly in the browser. It was released in 2017 and revolutionized E2E testing with its unique architecture and developer experience. Cypress provides a complete testing solution with a built-in test runner, time-travel debugging, and automatic waiting.

Key Characteristics

Architecture: Cypress runs in the same run-loop as your application, executing directly in the browser. This provides unique capabilities like real-time debugging and DOM access.

Developer Experience: Cypress prioritizes developer experience with features like time-travel debugging, automatic screenshots on failure, and a beautiful test runner UI.

Test Runner: Cypress includes a built-in test runner that provides real-time feedback, command logs, and visual debugging tools.

Core Strengths

Excellent DX: Time-travel debugging, automatic waiting, intuitive API
Built-in test runner: No need for external test runners
Real browser testing: Runs directly in the browser
Automatic screenshots/videos: Captures failures automatically
Large community: Extensive documentation and plugin ecosystem


Feature Comparison

Let’s compare Playwright and Cypress across key dimensions:

FeaturePlaywrightCypress
Browser SupportChromium, Firefox, WebKitChromium-based (Chrome, Edge, Electron)
Cross-Browser Testing✅ Native support⚠️ Limited (Chrome-focused)
Mobile Testing✅ Device emulation⚠️ Limited
Network Interception✅ Full control✅ Full control
Parallel Execution✅ Built-in⚠️ Requires Cypress Dashboard
Test Runner⚠️ External (Jest, Mocha)✅ Built-in
Time-Travel Debugging❌ No✅ Yes
Video Recording✅ Yes✅ Yes
Screenshot Support✅ Yes✅ Yes
TypeScript Support✅ Excellent✅ Good
API Testing✅ Yes✅ Yes
File Upload/Download✅ Yes✅ Yes
iFrame Support✅ Excellent⚠️ Limited
Multiple Tabs✅ Yes❌ No
Performance✅ Very Fast⚠️ Moderate

Setup and Installation

Installing Playwright

Playwright can be installed via npm or pnpm:

Terminal window
# Using pnpm (recommended)
pnpm add -D @playwright/test
# Or using npm
npm install -D @playwright/test
# Install browser binaries
npx playwright install

Playwright Configuration

Create a playwright.config.ts file:

import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
],
webServer: {
command: "pnpm dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});

Installing Cypress

Cypress can be installed via npm or pnpm:

Terminal window
# Using pnpm (recommended)
pnpm add -D cypress
# Or using npm
npm install -D cypress

Cypress Configuration

Create a cypress.config.ts file:

import { defineConfig } from "cypress";
export default defineConfig({
e2e: {
baseUrl: "http://localhost:3000",
setupNodeEvents(on, config) {
// implement node event listeners here
},
viewportWidth: 1280,
viewportHeight: 720,
video: true,
screenshotOnRunFailure: true,
},
});

💡 Pro Tip: Both tools support TypeScript out of the box. Use TypeScript for better type safety and autocomplete in your tests.


Writing Tests: Playwright

Playwright uses an async/await API with a clean, intuitive syntax. Tests are written using the test function and expect assertions.

Basic Test Example

import { test, expect } from "@playwright/test";
test("user can login successfully", async ({ page }) => {
// Navigate to login page
await page.goto("/login");
// Fill in credentials
await page.fill('[data-testid="email"]', "user@example.com");
await page.fill('[data-testid="password"]', "password123");
// Click login button
await page.click('[data-testid="login-button"]');
// Assert redirect to dashboard
await expect(page).toHaveURL("/dashboard");
await expect(page.locator("h1")).toContainText("Dashboard");
});

Working with Elements

Playwright provides multiple ways to select elements:

import { test, expect } from "@playwright/test";
test("element selection strategies", async ({ page }) => {
await page.goto("/products");
// By test ID (recommended)
const button = page.getByTestId("add-to-cart");
// By role (accessible)
const heading = page.getByRole("heading", { name: "Products" });
// By text
const link = page.getByText("View Details");
// By CSS selector
const card = page.locator(".product-card");
// By XPath (when necessary)
const element = page.locator('xpath=//button[@aria-label="Add"]');
await button.click();
});

Handling Async Operations

Playwright automatically waits for elements to be actionable:

import { test, expect } from "@playwright/test";
test("automatic waiting", async ({ page }) => {
await page.goto("/dashboard");
// Playwright waits for element to be visible and enabled
await page.click('button:has-text("Load Data")');
// Waits for network request to complete
await page.waitForResponse(
(response) =>
response.url().includes("/api/data") && response.status() === 200,
);
// Waits for element to appear
await expect(page.locator(".data-table")).toBeVisible();
});

Network Interception

Playwright allows you to intercept and modify network requests:

import { test, expect } from "@playwright/test";
test("mock API response", async ({ page }) => {
// Intercept and mock API call
await page.route("**/api/users", async (route) => {
const json = [
{ id: 1, name: "John Doe", email: "john@example.com" },
{ id: 2, name: "Jane Smith", email: "jane@example.com" },
];
await route.fulfill({ json });
});
await page.goto("/users");
await expect(page.locator(".user-card")).toHaveCount(2);
});

Multiple Browser Contexts

Playwright can test multiple browser contexts simultaneously:

import { test, expect } from "@playwright/test";
test("multi-user scenario", async ({ browser }) => {
// Create two separate browser contexts
const user1Context = await browser.newContext();
const user2Context = await browser.newContext();
const user1Page = await user1Context.newPage();
const user2Page = await user2Context.newPage();
// User 1 logs in
await user1Page.goto("/login");
await user1Page.fill('[name="email"]', "user1@example.com");
await user1Page.fill('[name="password"]', "password");
await user1Page.click('button[type="submit"]');
// User 2 logs in
await user2Page.goto("/login");
await user2Page.fill('[name="email"]', "user2@example.com");
await user2Page.fill('[name="password"]', "password");
await user2Page.click('button[type="submit"]');
// Both users can interact independently
await expect(user1Page.locator(".dashboard")).toBeVisible();
await expect(user2Page.locator(".dashboard")).toBeVisible();
await user1Context.close();
await user2Context.close();
});

Writing Tests: Cypress

Cypress uses a chainable API that reads like natural language. Tests are written using describe and it blocks (Mocha syntax).

Basic Test Example

describe("User Authentication", () => {
it("user can login successfully", () => {
// Navigate to login page
cy.visit("/login");
// Fill in credentials
cy.get('[data-testid="email"]').type("user@example.com");
cy.get('[data-testid="password"]').type("password123");
// Click login button
cy.get('[data-testid="login-button"]').click();
// Assert redirect to dashboard
cy.url().should("include", "/dashboard");
cy.get("h1").should("contain", "Dashboard");
});
});

Working with Elements

Cypress provides a chainable API for element selection:

describe("Element Selection", () => {
it("demonstrates selection strategies", () => {
cy.visit("/products");
// By test ID (recommended)
cy.get('[data-testid="add-to-cart"]').click();
// By data attribute
cy.get('[data-cy="product-card"]').should("be.visible");
// By CSS selector
cy.get(".product-card").first().click();
// By text content
cy.contains("View Details").click();
// Chaining commands
cy.get(".product-list")
.find(".product-item")
.should("have.length", 5)
.first()
.click();
});
});

Automatic Waiting

Cypress automatically waits for elements and assertions:

describe("Automatic Waiting", () => {
it("waits for elements automatically", () => {
cy.visit("/dashboard");
// Cypress automatically waits for element to exist and be visible
cy.get('button:contains("Load Data")').click();
// Waits for API call to complete
cy.intercept("GET", "/api/data").as("getData");
cy.wait("@getData");
// Waits for element to appear
cy.get(".data-table").should("be.visible");
});
});

Network Interception

Cypress allows you to intercept and stub network requests:

describe("API Mocking", () => {
it("mocks API response", () => {
// Intercept and stub API call
cy.intercept("GET", "/api/users", {
statusCode: 200,
body: [
{ id: 1, name: "John Doe", email: "john@example.com" },
{ id: 2, name: "Jane Smith", email: "jane@example.com" },
],
}).as("getUsers");
cy.visit("/users");
cy.wait("@getUsers");
cy.get(".user-card").should("have.length", 2);
});
});

Custom Commands

Cypress allows you to create custom commands for reusable actions:

cypress/support/commands.ts
declare global {
namespace Cypress {
interface Chainable {
login(email: string, password: string): Chainable<void>;
logout(): Chainable<void>;
}
}
}
Cypress.Commands.add("login", (email: string, password: string) => {
cy.visit("/login");
cy.get('[data-testid="email"]').type(email);
cy.get('[data-testid="password"]').type(password);
cy.get('[data-testid="login-button"]').click();
cy.url().should("include", "/dashboard");
});
Cypress.Commands.add("logout", () => {
cy.get('[data-testid="user-menu"]').click();
cy.get('[data-testid="logout-button"]').click();
cy.url().should("include", "/login");
});
export {};
// Using custom commands
describe("User Flow", () => {
it("completes user journey", () => {
cy.login("user@example.com", "password123");
cy.get(".dashboard").should("be.visible");
cy.logout();
cy.url().should("include", "/login");
});
});

Advanced Features Comparison

Screenshot and Video Recording

Playwright:

import { test } from "@playwright/test";
test("screenshot and video", async ({ page }) => {
// Take screenshot
await page.screenshot({ path: "screenshot.png", fullPage: true });
// Video is automatically recorded when configured
await page.goto("/dashboard");
// Video saved automatically on test failure or completion
});

Cypress:

describe("Screenshots and Videos", () => {
it("captures screenshots", () => {
cy.visit("/dashboard");
// Take screenshot
cy.screenshot("dashboard-view");
// Screenshots and videos are automatically captured on failure
// when configured in cypress.config.ts
});
});

Mobile Device Emulation

Playwright (excellent support):

import { test, devices } from "@playwright/test";
test("mobile view", async ({ browser }) => {
// Use device emulation
const iPhone = devices["iPhone 13"];
const context = await browser.newContext({
...iPhone,
});
const page = await context.newPage();
await page.goto("/mobile-app");
await expect(page.locator(".mobile-menu")).toBeVisible();
await context.close();
});

Cypress (limited support):

describe("Mobile Testing", () => {
it("tests mobile viewport", () => {
// Set viewport size
cy.viewport(375, 667); // iPhone size
cy.visit("/mobile-app");
cy.get(".mobile-menu").should("be.visible");
});
});

File Upload and Download

Playwright:

import { test, expect } from "@playwright/test";
import path from "path";
test("file upload", async ({ page }) => {
await page.goto("/upload");
// Upload file
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(path.join(__dirname, "test-file.pdf"));
await page.click('button:has-text("Upload")');
await expect(page.locator(".success-message")).toBeVisible();
});
test("file download", async ({ page }) => {
await page.goto("/download");
// Wait for download
const [download] = await Promise.all([
page.waitForEvent("download"),
page.click('a:has-text("Download Report")'),
]);
// Verify download
expect(download.suggestedFilename()).toBe("report.pdf");
await download.saveAs(path.join(__dirname, "downloaded-report.pdf"));
});

Cypress:

describe("File Operations", () => {
it("uploads file", () => {
cy.visit("/upload");
// Upload file
cy.get('input[type="file"]').selectFile("cypress/fixtures/test-file.pdf");
cy.get('button:contains("Upload")').click();
cy.get(".success-message").should("be.visible");
});
it("downloads file", () => {
cy.visit("/download");
// Download file
cy.get('a:contains("Download Report")').click();
// Verify download (requires cypress-downloadfile plugin)
cy.readFile("cypress/downloads/report.pdf").should("exist");
});
});

Performance and Reliability

Execution Speed

Playwright generally executes tests faster due to:

  • Parallel test execution out of the box
  • Efficient browser management
  • Multiple browser contexts
  • Optimized network interception
playwright.config.ts
// Playwright runs tests in parallel by default
export default defineConfig({
fullyParallel: true,
workers: 4, // Run 4 tests in parallel
});

Cypress execution speed:

  • Tests run sequentially by default
  • Parallel execution requires Cypress Dashboard (paid)
  • Slower due to running in browser context
cypress.config.ts
// Cypress runs tests sequentially
// For parallel execution, you need Cypress Dashboard
export default defineConfig({
e2e: {
// Configure parallel execution via dashboard
},
});

Reliability

Both tools handle flakiness well, but in different ways:

Playwright:

  • Auto-waiting for elements to be actionable
  • Network idle detection
  • Multiple retry strategies
  • Isolated browser contexts prevent test interference

Cypress:

  • Automatic retries on assertions
  • Built-in waiting mechanisms
  • Runs in real browser environment
  • Can be affected by application state

💡 Best Practice: Both tools benefit from proper test isolation. Each test should be independent and not rely on previous test state.


Browser Support and Cross-Browser Testing

Playwright Browser Support

Playwright supports three browser engines:

playwright.config.ts
export default defineConfig({
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
],
});

Advantages:

  • True cross-browser testing
  • Same API across all browsers
  • Consistent behavior
  • Easy to add/remove browsers

Cypress Browser Support

Cypress primarily supports Chromium-based browsers:

  • Chrome (recommended)
  • Edge (Chromium-based)
  • Electron
  • Firefox (experimental, limited)

⚠️ Limitations:

  • No Safari support
  • Firefox support is experimental
  • Cross-browser testing requires multiple configurations

Debugging and Developer Experience

Playwright Debugging

Playwright provides several debugging options:

import { test } from "@playwright/test";
test("debugging example", async ({ page }) => {
// Run in headed mode
// Use --headed flag: npx playwright test --headed
// Pause execution
await page.pause();
// Slow down operations
await page.goto("/dashboard", { waitUntil: "networkidle", timeout: 30000 });
// Take screenshot at any point
await page.screenshot({ path: "debug.png" });
// Console logging
page.on("console", (msg) => console.log("Browser:", msg.text()));
});

Playwright Inspector:

  • Visual debugging tool
  • Step through test execution
  • Inspect page state
  • Network request inspection

Cypress Debugging

Cypress excels at debugging with time-travel:

describe("Debugging", () => {
it("uses time-travel debugging", () => {
cy.visit("/dashboard");
// Cypress automatically provides:
// - Command log with all actions
// - Time-travel to any command
// - DOM snapshots at each step
// - Network request inspection
cy.get(".user-card").click();
cy.get(".user-details").should("be.visible");
// Use .debug() to pause execution
cy.get(".data-table").debug();
// Use .pause() to step through commands
cy.pause();
});
});

Cypress Advantages:

  • Time-travel debugging
  • Visual command log
  • DOM snapshots at each step
  • Built-in test runner UI

CI/CD Integration

Playwright CI/CD

Playwright integrates well with CI/CD pipelines:

.github/workflows/playwright.yml
name: Playwright Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: pnpm exec playwright test
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: playwright-report/

Cypress CI/CD

Cypress also integrates with CI/CD:

.github/workflows/cypress.yml
name: Cypress Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
cypress-run:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 18
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install
- name: Run Cypress tests
uses: cypress-io/github-action@v5
with:
start: pnpm dev
wait-on: "http://localhost:3000"
- uses: actions/upload-artifact@v3
if: failure()
with:
name: cypress-screenshots
path: cypress/screenshots
- uses: actions/upload-artifact@v3
if: always()
with:
name: cypress-videos
path: cypress/videos

When to Choose Playwright

Choose Playwright when:

You need true cross-browser testing

  • Testing on Firefox and Safari is important
  • You need consistent behavior across browsers

Performance is critical

  • Large test suites that need to run quickly
  • Parallel execution is important
  • CI/CD pipeline speed matters

You need advanced browser features

  • Multiple browser contexts/tabs
  • Mobile device emulation
  • Advanced network interception
  • File download handling

You’re building a complex application

  • Micro-frontends with multiple apps
  • Applications with iframes
  • Multi-tab workflows

You want flexibility

  • Use with any test runner (Jest, Mocha, etc.)
  • Integrate with existing test infrastructure
  • Language flexibility (TypeScript, Python, etc.)

Example Use Case: A SaaS application that needs to work across all browsers, has complex multi-tab workflows, and requires fast CI/CD pipelines.


When to Choose Cypress

Choose Cypress when:

Developer experience is paramount

  • Team values excellent debugging tools
  • Time-travel debugging is important
  • Visual test runner helps team adoption

You’re primarily targeting Chrome

  • Application is Chrome-focused
  • Cross-browser testing isn’t critical
  • Chrome-specific features are used

You want an all-in-one solution

  • Built-in test runner
  • No need to configure external tools
  • Integrated dashboard (with paid plan)

Your team is new to E2E testing

  • Easier learning curve
  • Better documentation and examples
  • Large community support

You need real-time debugging

  • Debugging failing tests is frequent
  • Visual feedback helps identify issues
  • Team prefers interactive testing

Example Use Case: A startup building a Chrome-focused web app, with a team new to E2E testing, who values excellent debugging tools and developer experience.


Migration Between Tools

Migrating from Cypress to Playwright

If you’re migrating from Cypress to Playwright:

// Cypress
cy.visit("/login");
cy.get('[data-testid="email"]').type("user@example.com");
cy.get('[data-testid="password"]').type("password");
cy.get('button[type="submit"]').click();
cy.url().should("include", "/dashboard");
// Playwright equivalent
await page.goto("/login");
await page.fill('[data-testid="email"]', "user@example.com");
await page.fill('[data-testid="password"]', "password");
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/dashboard/);

Key Differences:

  • Cypress uses chainable API, Playwright uses async/await
  • Cypress commands are synchronous-looking, Playwright is explicitly async
  • Cypress uses cy.get(), Playwright uses page.locator() or page.getBy*()
  • Cypress assertions use .should(), Playwright uses expect()

Migrating from Playwright to Cypress

If you’re migrating from Playwright to Cypress:

// Playwright
await page.goto("/login");
await page.fill('[data-testid="email"]', "user@example.com");
await page.click('button[type="submit"]');
await expect(page.locator(".dashboard")).toBeVisible();
// Cypress equivalent
cy.visit("/login");
cy.get('[data-testid="email"]').type("user@example.com");
cy.get('button[type="submit"]').click();
cy.get(".dashboard").should("be.visible");

Key Differences:

  • Playwright uses async/await, Cypress uses chainable API
  • Playwright uses expect(), Cypress uses .should()
  • Playwright uses page.locator(), Cypress uses cy.get()
  • Playwright supports multiple browsers, Cypress is Chrome-focused

💡 Migration Tip: Start by migrating critical user flows first, then gradually move other tests. Keep both tools running in parallel during migration to ensure nothing breaks.


Best Practices for E2E Testing

Test Organization

Structure tests logically:

tests/
e2e/
auth/
login.spec.ts
logout.spec.ts
products/
product-list.spec.ts
product-detail.spec.ts
checkout/
cart.spec.ts
payment.spec.ts

Use Page Object Model

Playwright Page Object:

pages/LoginPage.ts
import { Page, Locator } from "@playwright/test";
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly loginButton: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByTestId("email");
this.passwordInput = page.getByTestId("password");
this.loginButton = page.getByTestId("login-button");
}
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.loginButton.click();
}
}

Cypress Page Object:

pages/LoginPage.ts
export class LoginPage {
visit() {
cy.visit("/login");
}
fillEmail(email: string) {
cy.get('[data-testid="email"]').type(email);
}
fillPassword(password: string) {
cy.get('[data-testid="password"]').type(password);
}
clickLogin() {
cy.get('[data-testid="login-button"]').click();
}
login(email: string, password: string) {
this.visit();
this.fillEmail(email);
this.fillPassword(password);
this.clickLogin();
}
}

Test Data Management

Use fixtures for test data:

fixtures.ts
import { test as base } from "@playwright/test";
type TestFixtures = {
testUser: { email: string; password: string };
};
export const test = base.extend<TestFixtures>({
testUser: async ({}, use) => {
await use({
email: "test@example.com",
password: "testpassword123",
});
},
});
export { expect } from "@playwright/test";
// Cypress: cypress/fixtures/users.json
{
"validUser": {
"email": "test@example.com",
"password": "testpassword123"
},
"adminUser": {
"email": "admin@example.com",
"password": "adminpassword123"
}
}

Avoid Test Interdependence

Bad: Tests depend on each other

// ❌ Bad: Test depends on previous test
test("step 1", async ({ page }) => {
await page.goto("/login");
await page.fill('[name="email"]', "user@example.com");
await page.fill('[name="password"]', "password");
await page.click('button[type="submit"]');
// Leaves user logged in
});
test("step 2", async ({ page }) => {
// Assumes user is already logged in from previous test
await page.goto("/dashboard");
await expect(page.locator(".dashboard")).toBeVisible();
});

Good: Each test is independent

// ✅ Good: Each test is independent
test("user can access dashboard after login", async ({ page }) => {
// Complete flow in one test
await page.goto("/login");
await page.fill('[name="email"]', "user@example.com");
await page.fill('[name="password"]', "password");
await page.click('button[type="submit"]');
await expect(page).toHaveURL("/dashboard");
await expect(page.locator(".dashboard")).toBeVisible();
});

Use Data Attributes for Selectors

Recommended: Use data-testid attributes

<!-- HTML -->
<button data-testid="submit-button">Submit</button>
// Playwright
await page.getByTestId("submit-button").click();
// Cypress
cy.get('[data-testid="submit-button"]').click();

Avoid: Fragile CSS selectors

// ❌ Fragile: Breaks if CSS changes
await page.click(".btn.btn-primary.submit-btn");

Common Pitfalls and How to Avoid Them

Flaky Tests

Problem: Tests pass sometimes but fail randomly.

Solutions:

// ✅ Use proper waiting strategies
// Playwright
await page.waitForLoadState("networkidle");
await expect(page.locator(".content")).toBeVisible();
// Cypress
cy.wait("@apiCall"); // Wait for specific network request
cy.get(".content").should("be.visible");

Hardcoded Wait Times

Bad: Using fixed timeouts

// ❌ Bad: Fixed timeout
await page.waitForTimeout(5000); // Waits 5 seconds regardless

Good: Use proper waiting

// ✅ Good: Wait for specific conditions
await expect(page.locator(".content")).toBeVisible({ timeout: 10000 });

Testing Implementation Details

Bad: Testing how something is implemented

// ❌ Bad: Testing implementation
await expect(page.locator(".user-card")).toHaveClass("active");

Good: Testing user-visible behavior

// ✅ Good: Testing user-visible behavior
await expect(page.locator(".user-card")).toBeVisible();
await expect(page.locator(".user-name")).toContainText("John Doe");

Not Cleaning Up Test Data

Problem: Tests leave data that affects other tests.

Solution: Clean up after each test:

// Playwright
test.afterEach(async ({ page }) => {
// Clean up test data
await page.request.delete("/api/test-data");
});
// Cypress
afterEach(() => {
// Clean up test data
cy.request("DELETE", "/api/test-data");
});

Over-testing with E2E

Problem: Using E2E tests for everything.

Solution: Follow the testing pyramid. Use E2E tests for critical user journeys only. For unit and integration testing strategies, see our guide on test-driven development.


Conclusion

Both Playwright and Cypress are excellent E2E testing tools, each with unique strengths. Playwright excels at cross-browser testing, performance, and advanced browser features, making it ideal for applications that need to work across all browsers and require fast test execution. Cypress shines with its developer experience, time-travel debugging, and all-in-one solution, making it perfect for teams prioritizing ease of use and Chrome-focused applications.

When choosing between Playwright and Cypress, consider your specific needs:

  • Choose Playwright if you need cross-browser testing, performance, or advanced browser features
  • Choose Cypress if you prioritize developer experience, Chrome-focused testing, or an all-in-one solution

Remember that E2E tests should focus on critical user journeys rather than trying to test every possible interaction. Combine E2E tests with unit and integration tests for comprehensive coverage. Use tools like Jest or Vitest for unit testing, and reserve E2E tests for validating complete user workflows.

Regardless of which tool you choose, follow best practices: use page objects, avoid test interdependence, use proper waiting strategies, and focus on user-visible behavior rather than implementation details. With the right tool and practices, E2E testing becomes a powerful part of your quality assurance strategy.


Additional Resources