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
- What is End-to-End Testing?
- Playwright Overview
- Cypress Overview
- Feature Comparison
- Setup and Installation
- Writing Tests: Playwright
- Writing Tests: Cypress
- Advanced Features Comparison
- Performance and Reliability
- Browser Support and Cross-Browser Testing
- Debugging and Developer Experience
- CI/CD Integration
- When to Choose Playwright
- When to Choose Cypress
- Migration Between Tools
- Best Practices for E2E Testing
- Common Pitfalls and How to Avoid Them
- Conclusion
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:
| Feature | Playwright | Cypress |
|---|---|---|
| Browser Support | Chromium, Firefox, WebKit | Chromium-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:
# Using pnpm (recommended)pnpm add -D @playwright/test
# Or using npmnpm install -D @playwright/test
# Install browser binariesnpx playwright installPlaywright 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:
# Using pnpm (recommended)pnpm add -D cypress
# Or using npmnpm install -D cypressCypress 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:
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 commandsdescribe("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 runs tests in parallel by defaultexport 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 runs tests sequentially// For parallel execution, you need Cypress Dashboardexport 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:
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:
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:
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/videosWhen 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:
// Cypresscy.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 equivalentawait 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 usespage.locator()orpage.getBy*() - Cypress assertions use
.should(), Playwright usesexpect()
Migrating from Playwright to Cypress
If you’re migrating from Playwright to Cypress:
// Playwrightawait 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 equivalentcy.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 usescy.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.tsUse Page Object Model
Playwright Page Object:
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:
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:
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 testtest("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 independenttest("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>// Playwrightawait page.getByTestId("submit-button").click();
// Cypresscy.get('[data-testid="submit-button"]').click();❌ Avoid: Fragile CSS selectors
// ❌ Fragile: Breaks if CSS changesawait 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// Playwrightawait page.waitForLoadState("networkidle");await expect(page.locator(".content")).toBeVisible();
// Cypresscy.wait("@apiCall"); // Wait for specific network requestcy.get(".content").should("be.visible");Hardcoded Wait Times
❌ Bad: Using fixed timeouts
// ❌ Bad: Fixed timeoutawait page.waitForTimeout(5000); // Waits 5 seconds regardless✅ Good: Use proper waiting
// ✅ Good: Wait for specific conditionsawait expect(page.locator(".content")).toBeVisible({ timeout: 10000 });Testing Implementation Details
❌ Bad: Testing how something is implemented
// ❌ Bad: Testing implementationawait expect(page.locator(".user-card")).toHaveClass("active");✅ Good: Testing user-visible behavior
// ✅ Good: Testing user-visible behaviorawait 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:
// Playwrighttest.afterEach(async ({ page }) => { // Clean up test data await page.request.delete("/api/test-data");});
// CypressafterEach(() => { // 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.