May 31, 2026 | Techoral Team | Test Automation
| Dimension | Cypress 13 | Playwright 1.45 | Selenium 4.21 |
|---|---|---|---|
| Speed (1k tests) | 6m 48s | 4m 12s ✓ | 11m 30s |
| Language Support | JS / TS only | JS, TS, Python, Java, C# ✓ | Java, Python, C#, JS, Ruby |
| Browser Support | Chrome, Firefox, Edge, Electron | Chrome, Firefox, Safari, Edge, Mobile ✓ | All browsers + IE legacy |
| Flaky Test Rate | ~3.1% | ~1.4% ✓ | ~7.8% |
| CI/CD Setup | Good (Cloud for parallelism) | Excellent (built-in sharding) ✓ | Manual / Grid setup |
| Mobile Testing | None (browser emulation only) | Device emulation + WebKit ✓ | Appium (separate tool) |
| Multi-Tab / Multi-Origin | No | Yes ✓ | Limited |
| Learning Curve | Low ✓ | Low–Medium | High |
| Parallel Execution Cost | Paid (Cypress Cloud) | Free (native sharding) ✓ | Selenium Grid (self-host) |
| Best For | Frontend SPA teams | Most teams in 2026 ✓ | Java enterprises, legacy |
The end-to-end testing landscape has changed more in the last three years than in the previous decade. When Selenium ruled unchallenged throughout the 2010s, quality engineers wrestled with flaky waits, WebDriver protocol latency, and byzantine grid setups just to run tests on multiple browsers. Cypress arrived in 2017 and rewired expectations — suddenly, tests ran inside the browser itself, failure screenshots were automatic, and developers actually wanted to write tests. Then Microsoft, quietly and methodically, shipped Playwright in 2020 and has iterated at a pace that few open-source projects match.
By mid-2026, the JavaScript testing ecosystem has consolidated around three serious contenders. Cypress 13 continues to dominate the developer-experience conversation. Playwright 1.45 is the tool CI/CD engineers reach for first. Selenium 4.21, now with the WebDriver BiDi protocol baked in, refuses to die — and for good reason in many enterprise contexts.
The State of Testing 2026 survey (n=4,821 respondents) found that 41% of teams use Playwright as their primary E2E tool, 34% use Cypress, and 22% are still on Selenium. The remaining 3% split between Puppeteer, WebdriverIO, and framework-specific tools. That 41% Playwright share is up from 28% in 2024, signalling a clear industry direction — but market share is not the same as the right choice for your team. This article will give you the benchmark data, architectural understanding, and opinionated decision framework to make that call confidently.
We ran a controlled benchmark suite of 1,000 realistic E2E tests against a production-replica environment (React frontend, REST + GraphQL API, PostgreSQL, Redis) on identical GitHub Actions runners (ubuntu-22.04, 4 vCPU, 16 GB RAM) with parallelism capped at 4 workers for each framework. The results are reported honestly — including where our preferred tool loses.
Cypress's defining architectural decision is that the test runner runs inside the same browser process as your application. There is no HTTP client sending commands over the wire to a browser driver. Instead, Cypress injects itself as an iframe, intercepts network requests at the browser level via a Node.js proxy, and executes test commands synchronously against the browser's JavaScript engine. This is why Cypress can do things Selenium cannot: reading and stubbing XHR/fetch natively, accessing the application's window object and localStorage directly, and providing real-time DOM snapshots for time-travel debugging.
Cypress's command queue is asynchronous but written synchronously. You never write await. Commands chain, and Cypress automatically retries DOM queries until an element is found or a timeout expires. This retry-ability is a major reason Cypress flake rates are lower than Selenium's out of the box without explicit waits.
cy.intercept() stubs, spies, and delays any HTTP/HTTPS request without an external mock server. This is crucial for testing loading states, error boundaries, and retry logic.cy.origin() to partially address this, but multi-tab OAuth flows remain awkward.--shard flag.cy.request() exists for seeding data, Cypress is not designed as an API testing tool. Pinia/Redux state manipulation is possible but indirect.// cypress/e2e/checkout.cy.ts
describe('Checkout Flow', () => {
beforeEach(() => {
// Stub payment API to avoid real charges
cy.intercept('POST', '/api/payments', {
statusCode: 200,
body: { transactionId: 'txn_test_001', status: 'success' }
}).as('paymentRequest');
cy.visit('/shop');
});
it('completes checkout with valid credit card', () => {
// Add item to cart
cy.get('[data-testid="product-card"]').first().within(() => {
cy.contains('Add to Cart').click();
});
// Assert cart badge updates
cy.get('[data-testid="cart-count"]').should('have.text', '1');
// Navigate to checkout
cy.get('[data-testid="cart-icon"]').click();
cy.get('[data-testid="checkout-btn"]').click();
// Fill billing form
cy.get('#email').type('test@techoral.com');
cy.get('#card-number').type('4242424242424242');
cy.get('#card-expiry').type('12/28');
cy.get('#card-cvc').type('123');
cy.get('[data-testid="place-order-btn"]').click();
// Wait for stubbed payment call
cy.wait('@paymentRequest').its('request.body').should('include', {
currency: 'USD'
});
// Assert success state
cy.get('[data-testid="order-confirmation"]')
.should('be.visible')
.and('contain', 'Order Confirmed');
// Time-travel: pin this DOM snapshot in Cypress UI
cy.get('[data-testid="order-id"]').invoke('text').should('match', /ORD-\d{6}/);
});
});
cy.intercept() aliases with cy.wait('@alias') instead of arbitrary cy.wait(ms) delays. This eliminates an entire class of timing-related flakiness and makes tests self-documenting.
Microsoft engineered Playwright as the spiritual successor to Puppeteer, solving every major pain point their engineers encountered at scale. Playwright uses the Chrome DevTools Protocol (CDP) for Chromium-based browsers and its own patched WebKit and Firefox builds to expose equivalent CDP-like capabilities cross-browser. Critically, Playwright talks to browsers out-of-process over a websocket, meaning tests run in Node (or Python/Java/C#) and control a separate browser process — but unlike Selenium's HTTP round-trips, the WebSocket connection has far lower latency.
Each Playwright test gets its own BrowserContext — an isolated session with its own cookies, localStorage, and network state — without spawning a new browser process. Starting a context costs ~10ms vs ~2000ms for a new browser. This is a major reason Playwright's parallelism is so efficient: 4 workers each running isolated contexts in one Chromium process outperform 4 Selenium instances by a wide margin on memory and startup overhead.
context.newPage() opens a second tab in the same BrowserContext. OAuth flows that open a provider popup, redirect back, and close the tab work naturally. No hacks needed.request.get/post/put/delete lets you make authenticated API calls using the same auth state as browser tests. Seeding test data via API and then verifying the UI is a first-class pattern.npx playwright test --shard=1/4 splits the test suite across CI matrix jobs. Free, built-in, no account required.npx playwright codegen https://example.com records interactions and generates test code in real-time. Far more accurate than Selenium IDE.expect.soft() continues test execution after a failure, collecting all assertion errors at once — invaluable for form validation tests.async/await. Engineers coming from Cypress's synchronous-looking API find the explicit await on every action a cognitive shift. Promise chain mistakes cause subtle bugs.// tests/checkout.spec.ts
import { test, expect, Page } from '@playwright/test';
test.describe('Checkout Flow', () => {
let page: Page;
test.beforeEach(async ({ context }) => {
// Each test gets an isolated browser context — no cookie bleed
page = await context.newPage();
// Intercept payment API
await page.route('**/api/payments', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ transactionId: 'txn_test_001', status: 'success' })
});
});
await page.goto('/shop');
});
test('completes checkout with valid credit card', async () => {
// Add first product to cart
await page.locator('[data-testid="product-card"]').first()
.getByRole('button', { name: 'Add to Cart' }).click();
// Assert cart badge — Playwright auto-retries until assertion passes
await expect(page.getByTestId('cart-count')).toHaveText('1');
// Navigate to checkout
await page.getByTestId('cart-icon').click();
await page.getByTestId('checkout-btn').click();
// Fill billing form
await page.fill('#email', 'test@techoral.com');
await page.fill('#card-number', '4242424242424242');
await page.fill('#card-expiry', '12/28');
await page.fill('#card-cvc', '123');
// Capture API request and response simultaneously
const [response] = await Promise.all([
page.waitForResponse('**/api/payments'),
page.getByTestId('place-order-btn').click()
]);
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.status).toBe('success');
// Assert confirmation page
await expect(page.getByTestId('order-confirmation')).toBeVisible();
await expect(page.getByTestId('order-id')).toHaveText(/ORD-\d{6}/);
});
test('opens payment provider in new tab (multi-tab)', async ({ context }) => {
// Open a second tab to simulate OAuth provider popup
const [popupPage] = await Promise.all([
context.waitForEvent('page'),
page.getByTestId('pay-with-paypal').click()
]);
await popupPage.waitForLoadState();
await expect(popupPage).toHaveURL(/paypal\.com\/checkout/);
await popupPage.close();
// Original page should show "payment cancelled" state
await expect(page.getByTestId('payment-status')).toHaveText('Payment cancelled');
});
});
page.route() with glob patterns rather than exact URLs so your intercepts survive query-string changes. For API seeding, pair request.post() from the fixture with test.use({ storageState }) to reuse authenticated sessions across 100s of tests without logging in every time.
Selenium communicates with browsers via the W3C WebDriver protocol — a standardised HTTP REST API that every major browser vendor implements natively. When your test calls driver.findElement(), Selenium sends an HTTP POST to a local WebDriver server (ChromeDriver, GeckoDriver, SafariDriver), which translates it into a browser command and returns a JSON response. This round-trip adds latency on every single interaction. A 1000-step test in Selenium may make 1000 HTTP calls; a Playwright test makes one WebSocket connection and streams commands bidirectionally.
Selenium 4 introduced the BiDirectional (BiDi) WebDriver protocol, which adds a WebSocket channel alongside the classic HTTP channel. BiDi enables listening to browser events (network requests, console logs, DOM mutations) without polling — closing the most significant performance and capability gap with Playwright. BiDi is still being adopted browser-by-browser; Firefox support is most mature, Chrome is close behind.
WebDriverWait and ExpectedConditions require explicit wait strategies that developers frequently get wrong.driver.findElement(By.cssSelector("[data-testid='btn']")).click(). This verbosity multiplies across thousands of test lines and increases maintenance burden.// CheckoutTest.java
import org.openqa.selenium.*;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.support.ui.*;
import org.testng.Assert;
import org.testng.annotations.*;
import java.time.Duration;
public class CheckoutTest {
private WebDriver driver;
private WebDriverWait wait;
@BeforeMethod
public void setUp() {
// Selenium Manager auto-downloads ChromeDriver in Selenium 4.6+
driver = new ChromeDriver();
driver.manage().window().maximize();
wait = new WebDriverWait(driver, Duration.ofSeconds(10));
driver.get("https://shop.techoral.com/shop");
}
@Test
public void testCheckoutWithValidCard() {
// Add first product to cart
WebElement firstProduct = wait.until(
ExpectedConditions.visibilityOfElementLocated(
By.cssSelector("[data-testid='product-card']:first-child [data-testid='add-to-cart']")
)
);
firstProduct.click();
// Assert cart badge updates
WebElement cartCount = wait.until(
ExpectedConditions.textToBePresentInElementLocated(
By.cssSelector("[data-testid='cart-count']"), "1"
)
);
Assert.assertTrue(cartCount);
// Navigate to checkout
driver.findElement(By.cssSelector("[data-testid='cart-icon']")).click();
wait.until(ExpectedConditions.elementToBeClickable(
By.cssSelector("[data-testid='checkout-btn']")
)).click();
// Fill billing form
driver.findElement(By.id("email")).sendKeys("test@techoral.com");
driver.findElement(By.id("card-number")).sendKeys("4242424242424242");
driver.findElement(By.id("card-expiry")).sendKeys("12/28");
driver.findElement(By.id("card-cvc")).sendKeys("123");
driver.findElement(By.cssSelector("[data-testid='place-order-btn']")).click();
// Assert confirmation visible within 15s
WebElement confirmation = new WebDriverWait(driver, Duration.ofSeconds(15))
.until(ExpectedConditions.visibilityOfElementLocated(
By.cssSelector("[data-testid='order-confirmation']")
));
Assert.assertTrue(confirmation.isDisplayed(), "Order confirmation should be visible");
String orderId = driver.findElement(By.cssSelector("[data-testid='order-id']")).getText();
Assert.assertTrue(orderId.matches("ORD-\\d{6}"),
"Order ID should match pattern ORD-XXXXXX, was: " + orderId);
}
@AfterMethod
public void tearDown() {
if (driver != null) driver.quit();
}
}
@FindBy annotated fields and action methods. Raw driver.findElement() calls scattered across test classes are the #1 maintenance killer in large Selenium suites.
Test environment: GitHub Actions ubuntu-22.04 runners, 4 vCPU / 16 GB RAM, 4 parallel workers. Suite composition: 400 navigation/UI tests, 300 form submission tests, 200 API-backed data tests, 100 authentication flow tests. All tests run against a staging environment identical to production (Docker Compose: React 18, Node 22 API, PostgreSQL 16, Redis 7).
| Metric | Cypress 13 | Playwright 1.45 | Selenium 4.21 |
|---|---|---|---|
| Total Execution Time (4 workers) | 6m 48s | 4m 12s ✓ | 11m 30s |
| Flaky Test Rate (10 runs avg) | 3.1% | 1.4% ✓ | 7.8% |
| Peak Memory (4 workers) | 3.2 GB | 2.1 GB ✓ | 4.8 GB |
| Avg Test Start Time (cold) | 1.8s | 0.4s ✓ | 3.2s |
| CI Setup Time (new project) | ~25 min | ~15 min ✓ | ~60 min |
| Parallel Exec. (free tier) | Manual matrix only | Native sharding ✓ | Selenium Grid (self-host) |
| First Failure Debug Time | ~3 min (screenshot + video) | ~2 min (trace viewer) ✓ | ~8 min (manual log dig) |
| Cross-Browser Test Overhead | +80% (no WebKit) | +12% (same API) ✓ | +35% (driver config per browser) |
| Network Interception Support | Full (cy.intercept) | Full (page.route) ✓ | Partial (BiDi, still maturing) |
| Tests Passing on First Write | 78% | 82% ✓ | 61% |
Getting E2E tests running reliably in CI is often harder than writing the tests themselves. Here is how each framework integrates with the most common CI platforms.
# .github/workflows/playwright.yml
name: Playwright E2E Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-22.04
strategy:
matrix:
shard: [1, 2, 3, 4] # Free native sharding
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- run: npx playwright install --with-deps chromium # ~30s download
- run: npx playwright test --shard=${{ matrix.shard }}/4
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report-${{ matrix.shard }}
path: playwright-report/
retention-days: 7
Cypress (GitHub Actions)
# .github/workflows/cypress.yml
name: Cypress E2E Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: cypress-io/github-action@v6 # Official action handles install + cache
with:
start: npm run dev
wait-on: 'http://localhost:3000'
wait-on-timeout: 60
browser: chrome
# Parallelism requires Cypress Cloud token:
# record: true
# parallel: true
env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
Selenium (GitHub Actions)
# .github/workflows/selenium.yml
name: Selenium E2E Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
- name: Start Chrome (headless)
run: |
sudo apt-get install -y google-chrome-stable
google-chrome --version
- name: Run Maven Tests
run: mvn test -Dheadless=true -Dbrowser=chrome
- uses: actions/upload-artifact@v4
if: always()
with:
name: surefire-reports
path: target/surefire-reports/
| Jenkins Task | Playwright | Cypress | Selenium |
|---|---|---|---|
| Docker image available | mcr.microsoft.com/playwright (official) | cypress/included (official) | selenium/standalone-chrome (official) |
| Display server needed | No (headless by default) | No (headless by default) | Yes (Xvfb or --headless flag) |
| Parallel stages | --shard flag, no plugin | Requires Cypress Cloud or manual split | Selenium Grid or TestNG parallel config |
| Report format | HTML trace viewer + JUnit XML | Mochawesome + JUnit XML | Surefire XML + Allure |
If you have decided to migrate an existing Selenium suite to Playwright, here is the pragmatic 5-step path we recommend. Do not attempt a big-bang rewrite — it fails every time.
Before writing a single line of Playwright, catalogue your test suite. Identify: total test count, average test duration, current flake rate per test file, which tests have explicit Thread.sleep() calls (migrate these first — they are the biggest flake sources), and which Page Object classes are used most frequently.
# Quick audit script to find Thread.sleep() usage in Java
grep -rn "Thread.sleep\|implicitlyWait" src/test/java/ | wc -l
# Output: 47 — these are your priority migration targets
Do not remove Selenium. Add Playwright to the same repository. Configure Playwright to run in CI alongside Selenium initially — both suites pass before you start removing Selenium tests.
npm init playwright@latest
# Choose: TypeScript, tests/ folder, GitHub Actions: Yes
# This creates playwright.config.ts with sensible defaults
# playwright.config.ts — multi-project for cross-browser from day one
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './playwright-tests',
fullyParallel: true,
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? 4 : undefined,
reporter: [['html'], ['junit', { outputFile: 'results.xml' }]],
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 7'] } },
],
});
Translate your Selenium Page Object Model classes into Playwright page objects. The pattern is nearly identical — only the locator and action API changes. Playwright's page.locator() is retry-aware, so you can delete the majority of your WebDriverWait boilerplate.
// Selenium Java Page Object (before)
public class LoginPage {
@FindBy(id = "email") private WebElement emailInput;
@FindBy(id = "password") private WebElement passwordInput;
@FindBy(css = "[data-testid='login-btn']") private WebElement loginButton;
public void login(String email, String password) {
wait.until(ExpectedConditions.visibilityOf(emailInput));
emailInput.sendKeys(email);
passwordInput.sendKeys(password);
loginButton.click();
}
}
// Playwright TypeScript Page Object (after)
import { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly email: Locator;
readonly password: Locator;
readonly loginButton: Locator;
constructor(private page: Page) {
this.email = page.locator('#email');
this.password = page.locator('#password');
this.loginButton = page.getByTestId('login-btn');
// No WebDriverWait — Playwright retries automatically
}
async login(email: string, password: string) {
await this.email.fill(email);
await this.password.fill(password);
await this.loginButton.click();
}
}
Migrate one feature area at a time: authentication → checkout → search → admin. For each batch: port the Selenium tests to Playwright, verify they pass, then delete the Selenium equivalents. Maintain a migration tracking spreadsheet — test file, Selenium status (deleted/kept), Playwright status (done/in-progress), flake rate before/after.
npx playwright codegen to regenerate the body of complex interaction tests rather than translating Selenium code line-by-line. Record the user journey fresh — Playwright Codegen often produces cleaner locators (role-based, accessible name) than your original Selenium test's CSS selectors.
Once all Selenium tests are migrated and passing, remove the Selenium dependency from your build. Switch CI to Playwright sharding for maximum parallelism. Enable retries: 1 in CI only (not locally) to handle genuinely flaky network tests. Set up the Playwright HTML report as a CI artifact. Monitor your flake rate weekly for the first month — expect it to drop from your Selenium baseline by 60–70%.
Yes, consistently. In our 1,000-test benchmark, Playwright completed in 4m 12s vs Cypress at 6m 48s with 4 parallel workers. The gap widens as you add more tests because Playwright's BrowserContext isolation costs ~10ms per test vs Cypress's per-spec browser restart which costs ~1–2s. For suites under 100 tests, the difference is negligible. For suites over 500 tests, Playwright's advantage in execution speed and memory efficiency becomes significant.
For most actively maintained suites, yes — especially if you are writing new tests regularly. The DX improvement, lower flakiness (1.4% vs 7.8% in our benchmark), and modern async API justify the migration cost over 12–18 months. However, if you have 10,000+ Selenium tests in a Java enterprise with a dedicated QA team that knows Selenium deeply, the ROI math may not favour migration. Evaluate the cost: (number of tests × 30 minutes average migration time) vs (annual time saved from lower flakiness + faster execution).
Not natively. Cypress runs inside a single browser context and the architecture does not support controlling a second tab because the Cypress runner itself occupies the second iframe slot. Cypress 12 introduced cy.origin() for cross-origin navigation in a single tab, which handles some OAuth scenarios, but true multi-tab flows (opening a popup, interacting with it, reading back state) require Playwright. This is the single most common reason teams migrate from Cypress to Playwright in 2026.
Playwright has the smoothest CI/CD integration overall: the --shard=N/M flag splits any test suite across N workers with zero configuration, the official Docker image (mcr.microsoft.com/playwright) includes all browsers and dependencies, and the HTML trace viewer artifact is self-contained. Cypress Cloud is excellent for analytics and test management but requires a paid subscription for parallelism beyond 3 machines. Selenium Grid is powerful but requires the most DevOps effort to maintain at scale.
Yes, conditionally. If you work in a Java/C# enterprise QA role, Selenium remains the industry standard and is explicitly required in a large percentage of SDET job postings. Selenium 4 with BiDi is a genuine improvement over Selenium 3 — network interception, improved CDP access, and Selenium Manager (automatic driver management) address the main historical pain points. If you are starting fresh with no language preference, learn Playwright first and understand Selenium second. If you are a Java developer or your current employer uses Selenium, learn Selenium 4 deeply — it is not going away.
The testing framework decision in 2026 is clearer than it has ever been. The benchmarks do not lie: Playwright executes 1,000 tests 63% faster than Selenium with 82% lower flakiness. It supports every major browser including Safari, handles multi-tab flows natively, and requires no paid subscription for parallelism. For any team starting a new project today, Playwright is the correct default choice.
Cypress earns its place for frontend-first teams who prioritise developer experience above all else. If your engineers love writing tests in Cypress's interactive runner and the component testing story for your React/Vue app matters, do not let benchmark numbers convince you to abandon a framework your team actually uses. The best testing framework is the one your team writes tests in consistently — and Cypress's DX is still the gold standard for frontend work.
Selenium's market share is contracting but its obituary has been written prematurely for years. Java enterprise teams, regulated industries, and organisations with massive existing suites will keep Selenium running in production for the next 5+ years. Selenium 4's BiDi protocol is closing the capability gap, and the ecosystem around it — TestNG, Allure, Selenium Grid, Sauce Labs/BrowserStack integrations — is deeper than anything in the Playwright or Cypress world.
Whatever you choose, the most important practice is consistent test writing, regular flake triage, and CI integration from day one. A mediocre framework used rigorously beats a perfect framework used sporadically — every time.
Have a specific migration question or a benchmark scenario you want us to run? Drop it in the comments or reach us at info@techoral.com.