Playwright vs Cypress vs Puppeteer: E2E Testing in 2026
TL;DR
Playwright is the best E2E testing framework in 2026. Microsoft's investment has made it the fastest, most capable, and best-maintained option — cross-browser, cross-language, with first-class TypeScript and parallel execution. Cypress remains excellent for teams that value component testing integration and the interactive test runner. Puppeteer is a browser automation tool, not a test framework — use it for scraping, PDF generation, and automation tasks, not for application testing.
Key Takeaways
- Playwright: ~6.5M weekly downloads — Microsoft-backed, cross-browser, auto-waits, fastest at scale
- Cypress: ~5.2M weekly downloads — best interactive debugging experience, excellent component testing
- Puppeteer: ~3.1M weekly downloads — Chrome/Firefox only, better for automation than testing
- Playwright has grown from 1M to 6.5M weekly downloads in 18 months — the clear momentum winner
- Playwright wins for new E2E test suites: faster parallel execution, better cross-browser, Trace Viewer
- Cypress wins if you're testing React/Vue/Angular components alongside E2E tests
Download Trends
| Package | Weekly Downloads | Maintained By | Latest Version |
|---|---|---|---|
playwright / @playwright/test | ~6.5M | Microsoft | 1.x |
cypress | ~5.2M | Cypress.io | 13.x |
puppeteer | ~3.1M | 22.x |
Playwright
Playwright (by Microsoft) supports Chromium, Firefox, and WebKit with identical APIs. It's become the E2E standard for modern TypeScript projects.
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test"
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: true, // All tests run in parallel by default
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
reporter: [["html", { open: "never" }]],
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry", // Capture trace on failure
screenshot: "only-on-failure",
video: "on-first-retry",
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
{ name: "Mobile Chrome", use: { ...devices["Pixel 5"] } },
{ name: "Mobile Safari", use: { ...devices["iPhone 13"] } },
],
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
})
Writing Playwright tests:
import { test, expect } from "@playwright/test"
test.describe("Package search", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/")
})
test("searches for a package and shows results", async ({ page }) => {
// Auto-waits for elements — no explicit waits needed:
await page.getByPlaceholder("Search packages...").fill("react")
await page.getByRole("button", { name: "Search" }).click()
// Assertions auto-wait for the condition:
await expect(page.getByTestId("results-list")).toBeVisible()
await expect(page.getByText("react")).toBeVisible()
await expect(page.locator(".result-count")).toContainText("results")
})
test("navigates to package comparison page", async ({ page }) => {
await page.goto("/compare/react-vs-vue")
await expect(page).toHaveTitle(/React vs Vue/)
await expect(page.getByRole("heading", { level: 1 })).toContainText("React vs Vue")
// Check comparison table exists:
await expect(page.getByRole("table")).toBeVisible()
})
test("handles API errors gracefully", async ({ page }) => {
// Mock network requests:
await page.route("**/api/packages/**", (route) => {
route.fulfill({ status: 500, body: "Internal Server Error" })
})
await page.goto("/packages/react")
await expect(page.getByTestId("error-message")).toBeVisible()
await expect(page.getByText("Something went wrong")).toBeVisible()
})
})
Playwright Trace Viewer:
When a test fails, Playwright captures a full trace:
- Network requests with request/response
- Console logs
- DOM snapshots at each step
- Screenshots and video
npx playwright show-trace test-results/trace.zip
Playwright UI Mode:
npx playwright test --ui
Opens a browser-based interactive runner showing tests in real-time — similar to Cypress's interactive mode.
Page Object Model in Playwright:
// tests/pages/PackagePage.ts
import { Page, Locator } from "@playwright/test"
export class PackagePage {
readonly page: Page
readonly heading: Locator
readonly downloadChart: Locator
readonly compareButton: Locator
constructor(page: Page) {
this.page = page
this.heading = page.getByRole("heading", { level: 1 })
this.downloadChart = page.getByTestId("download-chart")
this.compareButton = page.getByRole("button", { name: /compare/i })
}
async goto(packageName: string) {
await this.page.goto(`/packages/${packageName}`)
}
async addToComparison() {
await this.compareButton.click()
return this.page.waitForURL(/\/compare\//)
}
}
// Usage in test:
test("package page shows download chart", async ({ page }) => {
const pkgPage = new PackagePage(page)
await pkgPage.goto("react")
await expect(pkgPage.downloadChart).toBeVisible()
})
Cypress
Cypress remains excellent for its interactive debugging experience and component testing integration:
// cypress.config.ts
import { defineConfig } from "cypress"
export default defineConfig({
e2e: {
baseUrl: "http://localhost:3000",
specPattern: "cypress/e2e/**/*.cy.ts",
supportFile: "cypress/support/e2e.ts",
},
component: {
devServer: {
framework: "next",
bundler: "webpack",
},
specPattern: "**/*.cy.tsx",
},
video: false,
screenshotOnRunFailure: true,
})
Cypress E2E test:
// cypress/e2e/package-search.cy.ts
describe("Package search", () => {
beforeEach(() => {
cy.visit("/")
})
it("searches and returns results", () => {
cy.findByPlaceholderText("Search packages...").type("react")
cy.findByRole("button", { name: "Search" }).click()
cy.findByTestId("results-list").should("be.visible")
cy.contains("react").should("be.visible")
})
it("intercepts API and stubs response", () => {
// Cypress's network interception:
cy.intercept("GET", "/api/packages/react", { fixture: "react-package.json" }).as("getPackage")
cy.visit("/packages/react")
cy.wait("@getPackage")
cy.get("h1").should("contain", "react")
})
})
Cypress Component Testing (unique capability):
// components/PackageCard.cy.tsx
import { PackageCard } from "./PackageCard"
describe("PackageCard", () => {
it("renders package information", () => {
cy.mount(
<PackageCard
name="react"
downloads={25000000}
version="18.2.0"
/>
)
cy.get("[data-testid='package-name']").should("have.text", "react")
cy.get("[data-testid='downloads']").should("contain", "25,000,000")
cy.get("[data-testid='version']").should("have.text", "18.2.0")
})
it("calls onCompare when button clicked", () => {
const onCompare = cy.stub().as("onCompare")
cy.mount(<PackageCard name="react" onCompare={onCompare} />)
cy.findByRole("button", { name: "Compare" }).click()
cy.get("@onCompare").should("have.been.calledWith", "react")
})
})
Cypress component tests run in the real browser with your actual dev server — same environment as E2E tests.
Puppeteer
Puppeteer is a browser automation library, not a test framework. The distinction matters:
import puppeteer from "puppeteer"
// Puppeteer use case 1: Screenshot generation
async function captureOGImage(url: string): Promise<Buffer> {
const browser = await puppeteer.launch({ headless: true })
const page = await browser.newPage()
await page.setViewport({ width: 1200, height: 630 })
await page.goto(url, { waitUntil: "networkidle0" })
const screenshot = await page.screenshot({ type: "png" })
await browser.close()
return screenshot
}
// Puppeteer use case 2: PDF generation
async function generatePDF(html: string): Promise<Buffer> {
const browser = await puppeteer.launch()
const page = await browser.newPage()
await page.setContent(html, { waitUntil: "networkidle0" })
const pdf = await page.pdf({ format: "A4", printBackground: true })
await browser.close()
return pdf
}
// Puppeteer use case 3: Web scraping
async function scrapeNpmDownloads(packageName: string): Promise<number> {
const browser = await puppeteer.launch()
const page = await browser.newPage()
await page.goto(`https://www.npmjs.com/package/${packageName}`)
const downloads = await page.$eval(
"[data-testid='weekly-downloads']",
(el) => parseInt(el.textContent?.replace(/,/g, "") ?? "0")
)
await browser.close()
return downloads
}
Why Puppeteer is not the right choice for testing:
// Puppeteer testing requires significant manual work:
// - No built-in test runner (need Jest + separate runner)
// - No auto-waiting (must manually wait for elements/conditions)
// - No built-in assertions (use expect from Jest)
// - No retry logic
// - No parallel execution management
// - No trace/video/screenshot on failure
// Instead of:
await page.waitForSelector(".results", { timeout: 30000 })
const results = await page.$(".results")
expect(results).toBeTruthy()
// Playwright gives you:
await expect(page.getByTestId("results")).toBeVisible()
// Auto-waits, auto-retries, clear error messages
Performance Comparison
Running 100 E2E tests against a Next.js app:
| Tool | Sequential | Parallel (4 workers) | CI Setup Difficulty |
|---|---|---|---|
| Playwright | ~8 min | ~2.5 min | Low |
| Cypress | ~10 min | ~3 min (via dashboard) | Medium |
| Puppeteer + Jest | ~12 min | ~4 min | High |
Playwright's out-of-the-box parallel execution requires no additional configuration — fullyParallel: true in config.
Feature Comparison
| Feature | Playwright | Cypress | Puppeteer |
|---|---|---|---|
| Browser support | Chromium, Firefox, WebKit | Chrome, Firefox, Edge | Chrome, Firefox |
| Parallel execution | ✅ Built-in | ✅ (Cypress Cloud $) | ❌ Manual |
| Auto-wait | ✅ Excellent | ✅ Good | ❌ Manual |
| Component testing | ❌ | ✅ Excellent | ❌ |
| Visual testing | ✅ (screenshots) | ✅ (plugins) | ✅ |
| Network mocking | ✅ | ✅ | ✅ |
| Trace on failure | ✅ Trace Viewer | ⚠️ Video only | ❌ |
| TypeScript | ✅ First-class | ✅ | ✅ |
| Multi-tab/window | ✅ | ❌ (same origin only) | ✅ |
| Mobile emulation | ✅ | ⚠️ Limited | ✅ |
| Interactive runner | ✅ UI Mode | ✅ Cypress App | ❌ |
Migration: Cypress → Playwright
// Cypress:
cy.visit("/packages/react")
cy.get("[data-testid='download-chart']").should("be.visible")
cy.intercept("GET", "/api/npm/*", { fixture: "npm-data.json" })
// Playwright equivalent:
await page.goto("/packages/react")
await expect(page.getByTestId("download-chart")).toBeVisible()
await page.route("/api/npm/**", (route) => route.fulfill({ path: "fixtures/npm-data.json" }))
The API surface is similar — most Cypress tests can be mechanically translated to Playwright in a few hours.
When to Use Each
Choose Playwright if:
- Starting a new E2E test suite in 2026
- You need cross-browser testing (WebKit/Safari coverage)
- CI performance matters (parallel execution is free)
- Multi-tab testing is required
Choose Cypress if:
- You need component testing alongside E2E (single framework)
- Your team values the interactive debugging experience
- You're already invested in the Cypress ecosystem
Use Puppeteer for:
- Screenshot/OG image generation
- PDF generation from HTML
- Web scraping
- Chrome DevTools Protocol automation
Don't use Puppeteer as an E2E test framework — it lacks auto-waiting, assertions, reporting, and parallel execution that Playwright and Cypress provide out of the box.
Methodology
Download data from npm registry (weekly average, February 2026). Performance benchmarks are approximate based on community reports and official documentation. Feature comparison based on Playwright 1.4x, Cypress 13.x, and Puppeteer 22.x documentation.